Add bookings.tsx

This commit is contained in:
Arthur Belleville 2025-09-15 09:01:08 +02:00
parent a897b2d21e
commit 00b1ee61a3
No known key found for this signature in database
15 changed files with 1243 additions and 184 deletions

View file

@ -71,6 +71,7 @@
"ag-grid-community": "^33.2.1",
"ag-grid-react": "^33.2.1",
"axios": "^1.8.4",
"date-fns": "^4.1.0",
"jspdf": "^3.0.1",
"jwt-decode": "^4.0.0",
"react-router-dom": "^7.3.0",

View file

@ -38,6 +38,9 @@ importers:
axios:
specifier: ^1.8.4
version: 1.8.4
date-fns:
specifier: ^4.1.0
version: 4.1.0
jspdf:
specifier: ^3.0.1
version: 3.0.1
@ -2662,6 +2665,9 @@ packages:
resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
engines: {node: '>= 0.4'}
date-fns@4.1.0:
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
dayjs@1.11.13:
resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==}
@ -8266,6 +8272,8 @@ snapshots:
es-errors: 1.3.0
is-data-view: 1.0.2
date-fns@4.1.0: {}
dayjs@1.11.13: {}
debug@4.4.0:

View file

@ -124,7 +124,7 @@ export function ChannelPreview({
{unreadCount > 0 && (
<div className="ml-2 flex-shrink-0">
<Badge
variant="in-review"
color="indigo"
className="text-xs min-w-[20px] h-5 px-2 py-0 flex items-center justify-center"
>
{unreadCount > 99 ? "99+" : unreadCount}

View file

@ -0,0 +1,57 @@
// Custom Modal Component
interface CustomModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
export function CustomModal({
isOpen,
onClose,
title,
children,
}: CustomModalProps) {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="relative bg-white dark:bg-gray-800 rounded-xl shadow-2xl border border-gray-200 dark:border-gray-700 w-full max-w-md mx-4 max-h-[90vh] overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
{title}
</h2>
<button
onClick={onClose}
className="p-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
<svg
className="w-5 h-5 text-gray-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{/* Content */}
<div className="p-6">{children}</div>
</div>
</div>
);
}

View file

@ -0,0 +1,181 @@
import { EventAndTablo } from "@ui/types/events.types";
import {
Dialog,
DialogHeader,
DialogBody,
DialogCloseButton,
} from "@ui/ui-library/dialog";
import { Text, Strong } from "@ui/ui-library/text";
import { Button } from "@ui/ui-library/button";
import { CalendarIcon, User } from "lucide-react";
import { CustomModal } from "./CustomModal";
interface EventDetailsModalProps {
event: EventAndTablo | null;
isOpen: boolean;
onClose: () => void;
onEdit?: () => void;
}
export const EventDetailsModal = ({
event,
isOpen,
onClose,
onEdit,
}: EventDetailsModalProps) => {
if (!event) return null;
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-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
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-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
À venir
</span>
);
} else {
return (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200">
Passé
</span>
);
}
};
return (
<CustomModal
isOpen={isOpen}
onClose={onClose}
title={event.title || "Événement sans titre"}
>
<Dialog>
<DialogHeader>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 truncate">
{event.title || "Événement sans titre"}
</h2>
<div className="mt-1">{getEventStatusBadge(event)}</div>
</div>
</div>
<DialogCloseButton />
</DialogHeader>
<DialogBody className="space-y-6">
{/* Date and Time */}
<div className="flex items-start space-x-3">
<CalendarIcon className="w-5 h-5 text-gray-400 mt-0.5" />
<div>
<Strong className="text-sm text-gray-900 dark:text-gray-100">
Date et heure
</Strong>
<Text className="text-gray-600 dark:text-gray-300">
{formatEventDateTime(event)}
</Text>
</div>
</div>
{/* Tablo */}
{event.tablo_name && (
<div className="flex items-start space-x-3">
<div
className="w-5 h-5 rounded mt-0.5"
style={{ backgroundColor: event.tablo_color || "#6b7280" }}
/>
<div>
<Strong className="text-sm text-gray-900 dark:text-gray-100">
Tableau
</Strong>
<Text className="text-gray-600 dark:text-gray-300">
{event.tablo_name}
</Text>
</div>
</div>
)}
{/* Description */}
{event.description && (
<div className="flex items-start space-x-3">
<User className="w-5 h-5 text-gray-400 mt-0.5" />
<div className="flex-1">
<Strong className="text-sm text-gray-900 dark:text-gray-100">
Description
</Strong>
<Text className="text-gray-600 dark:text-gray-300 whitespace-pre-wrap">
{event.description}
</Text>
</div>
</div>
)}
{/* Event ID (for debugging/reference) */}
<div className="pt-4 border-t border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
<span>ID: {event.event_id}</span>
{event.tablo_id && <span>Tableau ID: {event.tablo_id}</span>}
</div>
</div>
</DialogBody>
{/* Footer */}
<div className="flex justify-end space-x-3 px-6 py-4 bg-gray-50 dark:bg-gray-800">
<Button variant="outline" onPress={onClose}>
Fermer
</Button>
{onEdit && (
<Button
className="bg-emerald-700 text-white hover:bg-emerald-600"
onPress={() => {
onEdit();
onClose();
}}
>
Modifier
</Button>
)}
</div>
</Dialog>
</CustomModal>
);
};

View file

@ -20,6 +20,7 @@ import { ChatPage } from "@ui/pages/chat";
import { FeedbackPage } from "@ui/pages/feedback";
import { SupportPage } from "@ui/pages/support";
import { AvailabilitiesPage } from "@ui/pages/availabilities";
import { BookingsPage } from "@ui/pages/bookings";
export const routes: RouteObject[] = [
// Protected routes
@ -78,6 +79,11 @@ export const routes: RouteObject[] = [
path: "availabilities",
element: <AvailabilitiesPage />,
},
{
path: "bookings",
element: <BookingsPage />,
children: [{ index: true }, { path: ":tablo_id" }],
},
{
path: "feedback",
element: <FeedbackPage />,

View file

@ -17,59 +17,7 @@ import { DatePicker, DatePickerInput } from "@ui/ui-library/date-picker";
import { Label } from "@ui/ui-library/field";
import { DateValue, getLocalTimeZone, today } from "@internationalized/date";
import { RadioGroup, Radios, Radio } from "@ui/ui-library/radio-group";
// Custom Modal Component
interface CustomModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
function CustomModal({ isOpen, onClose, title, children }: CustomModalProps) {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="relative bg-white dark:bg-gray-800 rounded-xl shadow-2xl border border-gray-200 dark:border-gray-700 w-full max-w-md mx-4 max-h-[90vh] overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
{title}
</h2>
<button
onClick={onClose}
className="p-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
<svg
className="w-5 h-5 text-gray-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{/* Content */}
<div className="p-6">{children}</div>
</div>
</div>
);
}
import { CustomModal } from "@ui/components/CustomModal";
const DAYS_OF_WEEK = [0, 1, 2, 3, 4, 5, 6];
const DAYS_OF_WEEK_DISPLAY = [

543
ui/src/pages/bookings.tsx Normal file
View file

@ -0,0 +1,543 @@
import { useState, useMemo, useEffect } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { Text, Strong } from "@ui/ui-library/text";
import { Button } from "@ui/ui-library/button";
import {
Select,
SelectButton,
SelectListBox,
SelectListItem,
SelectListItemLabel,
SelectPopover,
StatusIcon,
} from "@ui/ui-library/select";
import { Input } from "@ui/ui-library/field";
import { SearchIcon } from "lucide-react";
import { CalendarIcon } from "@ui/ui-library/icons/outline/calendar";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useEventsByTablo } from "@ui/hooks/events";
import { useTablosList } from "@ui/hooks/tablos";
import { EventAndTablo } from "@ui/types/events.types";
import { LoadingSpinner } from "@ui/components/LoadingSpinner";
import { EventDetailsModal } from "@ui/components/EventDetailsModal";
import { RadioGroup, Radios, Radio } from "@ui/ui-library/radio-group";
import { Badge } from "@ui/ui-library/badge";
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
);
// 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(b.start_date).getTime() - new Date(a.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-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
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-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
À venir
</span>
);
} else {
return (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200">
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}`);
};
const handleEditEvent = (event: EventAndTablo) => {
if (event.event_id && event.tablo_id) {
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-white dark:bg-gray-800 shadow">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Réservations
</h1>
<Text className="text-gray-600 dark:text-gray-400">
Gérez vos événements et réservations
</Text>
</div>
<div className="flex items-center space-x-3">
<Button
className="bg-emerald-700 text-white hover:bg-emerald-600"
onPress={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-white dark:bg-gray-800 rounded-lg shadow 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-gray-400 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
selectedKey={selectedTabloId}
onSelectionChange={(key) => setSelectedTabloId(key as string)}
>
<SelectButton className="w-full h-10 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 dark:bg-gray-700 dark:text-white text-left bg-white hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors" />
<SelectPopover>
<SelectListBox>
<SelectListItem id="all">Tous les tableaux</SelectListItem>
{tablos?.map((tablo) => (
<SelectListItem key={tablo.id} id={tablo.id}>
<StatusIcon className={tablo.color || "#6b7280"} />
<SelectListItemLabel>{tablo.name}</SelectListItemLabel>
</SelectListItem>
))}
</SelectListBox>
</SelectPopover>
</Select>
</div>
{/* Status Filter */}
<div className="flex items-center h-10">
<RadioGroup
orientation="horizontal"
defaultValue={"upcoming"}
onChange={(value) => setStatusFilter(value as BookingStatus)}
>
<Radios className="flex gap-y-0 mt-0">
{statusOptions.map((option) => {
return (
<Radio
key={option.id}
value={option.id}
radio={null}
className="rounded-md"
>
{({ isSelected }: { isSelected: boolean }) => {
return (
<Badge
color="black"
{...(isSelected && {
variant: "solid",
})}
className="rounded-full"
>
{option.name}
</Badge>
);
}}
</Radio>
);
})}
</Radios>
</RadioGroup>
</div>
</div>
</div>
{/* Events List */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
{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-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">
Aucun événement trouvé
</h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{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-gray-200 dark:divide-gray-700">
{paginatedEvents.map((event) => (
<div
key={event.event_id}
className="p-6 hover:bg-gray-50 dark:hover:bg-gray-700 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-gray-900 dark:text-gray-100 truncate">
{event.title || "Événement sans titre"}
</Strong>
{getEventStatusBadge(event)}
</div>
<div className="flex items-center space-x-4 text-sm text-gray-500 dark:text-gray-400 mb-2">
<span className="flex items-center">
<CalendarIcon className="w-4 h-4 mr-1" />
{formatEventDateTime(event)}
</span>
{event.tablo_name && (
<span
className="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium"
style={{
backgroundColor: event.tablo_color
? `${event.tablo_color}20`
: "#f3f4f6",
color: event.tablo_color || "#6b7280",
}}
>
{event.tablo_name}
</span>
)}
</div>
{event.description && (
<Text className="text-gray-600 dark:text-gray-300 line-clamp-2">
{event.description}
</Text>
)}
</div>
<div className="flex items-center space-x-2 ml-4">
<Button
variant="outline"
size="sm"
onPress={() => handleEditEvent(event)}
>
Modifier
</Button>
<Button
variant="outline"
size="sm"
onPress={() => handleViewEvent(event)}
>
Détails
</Button>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Pagination Controls */}
{totalItems > 0 && (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow mt-4 px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4 text-sm text-gray-500 dark:text-gray-400">
<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
selectedKey={itemsPerPage.toString()}
onSelectionChange={(key) => setItemsPerPage(Number(key))}
>
<SelectButton className="min-w-16 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700" />
<SelectPopover>
<SelectListBox>
<SelectListItem id="5">5</SelectListItem>
<SelectListItem id="10">10</SelectListItem>
<SelectListItem id="20">20</SelectListItem>
<SelectListItem id="50">50</SelectListItem>
</SelectListBox>
</SelectPopover>
</Select>
</div>
</div>
{totalPages > 1 && (
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onPress={() =>
setCurrentPage((prev) => Math.max(prev - 1, 1))
}
isDisabled={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-gray-400">...</span>
)}
<Button
variant={
currentPage === page ? "solid" : "outline"
}
size="sm"
onPress={() => setCurrentPage(page)}
className={
currentPage === page
? "bg-emerald-700 text-white hover:bg-emerald-600"
: ""
}
>
{page}
</Button>
</div>
);
})}
</div>
<Button
variant="outline"
size="sm"
onPress={() =>
setCurrentPage((prev) => Math.min(prev + 1, totalPages))
}
isDisabled={currentPage === totalPages}
>
Suivant
<ChevronRight className="w-4 h-4" />
</Button>
</div>
)}
</div>
</div>
)}
{/* Stats Summary */}
{filteredEvents.length > 0 && (
<div className="mt-6 bg-white dark:bg-gray-800 rounded-lg shadow 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-gray-900 dark:text-gray-100">
{filteredEvents.length}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
Événements trouvés
</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-emerald-600">
{
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-gray-500 dark:text-gray-400">
À venir
</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-blue-600">
{
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-gray-500 dark:text-gray-400">
Aujourd&apos;hui
</div>
</div>
</div>
</div>
)}
{/* Event Details Modal */}
<EventDetailsModal
event={selectedEvent}
isOpen={isDetailsModalOpen}
onClose={() => {
setIsDetailsModalOpen(false);
setSelectedEvent(null);
}}
onEdit={() => selectedEvent && handleEditEvent(selectedEvent)}
/>
</main>
</div>
);
};

View file

@ -32,7 +32,7 @@ import {
statusToText,
} from "@ui/utils/helpers";
import { ViewDevisModal } from "@ui/components/devis/ViewDevisModal";
import { Badge, BadgeVariant } from "@ui/ui-library/badge";
import { Badge, BadgeColor } from "@ui/ui-library/badge";
import { Select, SelectButton } from "@ui/ui-library/select";
import { SelectListBox } from "@ui/ui-library/select";
import { SelectPopover } from "@ui/ui-library/select";
@ -42,12 +42,12 @@ ModuleRegistry.registerModules([AllCommunityModule]);
type Devis = Database["public"]["Tables"]["devis"]["Row"];
const statusToVariant: Record<Devis["status"], BadgeVariant> = {
draft: "neutral",
sent: "in-review",
accepted: "done",
rejected: "danger",
expired: "notice",
const statusToVariant: Record<Devis["status"], BadgeColor> = {
draft: "zinc",
sent: "indigo",
accepted: "green",
rejected: "red",
expired: "yellow",
};
export const DevisPage = () => {
@ -177,7 +177,7 @@ export const DevisPage = () => {
<SelectListBox checkIconPlacement="start">
{Object.entries(statusToVariant).map(([status]) => (
<SelectListItem key={status} id={status} textValue={status}>
<Badge variant={statusToVariant[status as Devis["status"]]}>
<Badge color={statusToVariant[status as Devis["status"]]}>
{statusToText[status as Devis["status"]]}
</Badge>
</SelectListItem>

View file

@ -1,42 +0,0 @@
import React from "react";
const baseStyles =
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2";
const variantStyles = {
neutral:
"border-transparent bg-gray-100 text-gray-800 hover:bg-gray-100/80 dark:bg-gray-800 dark:text-gray-50 dark:hover:bg-gray-800/80",
done: "border-transparent bg-green-100 text-green-800 hover:bg-green-100/80 dark:bg-green-900 dark:text-green-50 dark:hover:bg-green-900/80",
danger:
"border-transparent bg-red-100 text-red-800 hover:bg-red-100/80 dark:bg-red-900 dark:text-red-50 dark:hover:bg-red-900/80",
notice:
"border-transparent bg-yellow-100 text-yellow-800 hover:bg-yellow-100/80 dark:bg-yellow-900 dark:text-yellow-50 dark:hover:bg-yellow-900/80",
"in-review":
"border-transparent bg-blue-100 text-blue-800 hover:bg-blue-100/80 dark:bg-blue-900 dark:text-blue-50 dark:hover:bg-blue-900/80",
};
export type BadgeVariant = keyof typeof variantStyles;
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
variant?: BadgeVariant;
children: React.ReactNode;
}
function Badge({
className,
variant = "neutral",
children,
...props
}: BadgeProps) {
const combinedClassName = [baseStyles, variantStyles[variant], className]
.filter(Boolean)
.join(" ");
return (
<div className={combinedClassName} {...props}>
{children}
</div>
);
}
export { Badge };

View file

@ -0,0 +1,95 @@
import { ClassNameValue } from "tailwind-merge";
const colors = {
zinc: "[--badge:var(--color-zinc-500)]",
red: "[--badge:var(--color-red-500)]",
orange: "[--badge:var(--color-orange-500)]",
amber: "[--badge:var(--color-amber-500)]",
yellow: "[--badge:var(--color-yellow-500)]",
lime: "[--badge:var(--color-lime-500)]",
green: "[--badge:var(--color-green-500)]",
emerald: "[--badge:var(--color-emerald-500)]",
teal: "[--badge:var(--color-teal-500)]",
cyan: "[--badge:var(--color-cyan-500)]",
sky: "[--badge:var(--color-sky-500)]",
blue: "[--badge:var(--color-blue-500)]",
indigo: "[--badge:var(--color-indigo-500)]",
violet: "[--badge:var(--color-violet-500)]",
purple: "[--badge:var(--color-purple-500)]",
fuchsia: "[--badge:var(--color-fuchsia-500)]",
pink: "[--badge:var(--color-pink-500)]",
rose: "[--badge:var(--color-rose-500)]",
};
export type BadgeColor = keyof typeof colors | "white" | "black";
export type BadgeVariant = "solid";
export function getBadgeStyles(
{
color = "zinc",
variant,
}: {
color?: BadgeColor;
variant?: BadgeVariant;
},
className?: ClassNameValue
) {
const base = [
"inline-flex max-w-fit cursor-default items-center gap-x-1 rounded-md px-2 py-0.5 text-xs/5 font-medium outline-0 transition [&>[data-ui=icon]:not([class*=size-])]:size-3.5",
];
if (color === "white") {
return [
base,
variant === "solid"
? "border border-accent bg-accent text-[--btn-color:lch(from_var(--accent)_calc((49.44_-_l)_*_infinity)_0_0)] data-selection-mode:hover:opacity-85"
: "border data-selection-mode:hover:bg-zinc-100 data-selection-mode:dark:hover:bg-zinc-700",
className,
];
}
if (color === "black") {
return [
base,
variant === "solid"
? "bg-zinc-950 text-white dark:bg-white dark:text-zinc-950 data-selection-mode:hover:opacity-85"
: [
"bg-zinc-200",
"text-zinc-900",
"data-selection-mode:hover:bg-zinc-950",
"data-selection-mode:hover:text-white ",
"dark:bg-zinc-600",
"dark:text-white",
"data-selection-mode:dark:hover:bg-zinc-700",
],
className,
];
}
return [
base,
"text-(--color)",
"bg-(--bg)",
colors[color] ?? colors.zinc,
variant === "solid"
? [
"[--bg:color-mix(in_oklab,_var(--badge)_90%,_black)]",
"[--color:color-mix(in_oklab,_var(--badge)_5%,_white)]",
"data-selection-mode:hover:[--bg:color-mix(in_oklab,_var(--badge)_80%,_black)]",
]
: [
// light
"[--bg:color-mix(in_oklab,_var(--badge)_10%,_white)]",
"[--color:color-mix(in_oklab,_var(--badge)_80%,_black)]",
"data-selection-mode:hover:[--bg:color-mix(in_oklab,_var(--badge)_30%,_white)]",
// dark
"dark:[--bg:color-mix(in_oklab,_var(--badge)_40%,_black)]",
"dark:[--color:color-mix(in_oklab,_var(--badge)_98%,_black)]",
"data-selection-mode:hover:dark:[--bg:color-mix(in_oklab,_var(--badge)_30%,_black)]",
],
className,
];
}

View file

@ -0,0 +1,19 @@
import { twMerge } from "tailwind-merge";
import { BadgeColor, getBadgeStyles } from "./badge.styles";
export function Badge({
className,
color = "zinc",
variant,
...props
}: React.JSX.IntrinsicElements["div"] & {
color?: BadgeColor;
variant?: "solid";
}) {
return (
<div
{...props}
className={twMerge(getBadgeStyles({ color, variant }), className)}
/>
);
}

View file

@ -0,0 +1,5 @@
export type { BadgeColor } from "./badge.styles";
export { getBadgeStyles } from "./badge.styles";
export { Badge } from "./badge";

View file

@ -1,4 +1,4 @@
import React from 'react';
import React from "react";
import {
composeRenderProps,
Radio as RACRadio,
@ -6,35 +6,120 @@ import {
RadioGroupProps as RACRadioGroupProps,
RadioProps as RACRadioProps,
RadioRenderProps,
} from 'react-aria-components';
import { twMerge } from 'tailwind-merge';
import { DescriptionContext, DescriptionProvider } from './field';
import { composeTailwindRenderProps, groupBox } from './utils';
} from "react-aria-components";
import { twMerge } from "tailwind-merge";
import { DescriptionContext, DescriptionProvider } from "./field";
import { composeTailwindRenderProps, groupBox } from "./utils";
type RadioGroupVariant = {
orientation?: "vertical" | "horizontal";
labelPlacement?: "start" | "end";
} & (
| {
variant?: "card";
compact?: true;
}
| {
variant?: "radio" | "segment";
compact?: never;
}
);
const RadioGroupVariantContext = React.createContext<RadioGroupVariant | null>(
null
);
const useRadioGroupVariantContext = () => {
const {
labelPlacement = "end",
orientation = "vertical",
variant = "radio",
compact = false,
} = React.useContext(RadioGroupVariantContext) ?? {};
return {
labelPlacement,
orientation,
variant,
compact,
} as RadioGroupVariant;
};
export function RadioGroup({
children,
variant = "radio",
orientation,
labelPlacement = "end",
compact,
...props
}: RACRadioGroupProps & Exclude<RadioGroupVariant, "orientation">) {
if (variant === "segment") {
orientation = orientation ?? "horizontal";
}
export function RadioGroup(props: RACRadioGroupProps) {
return (
<RACRadioGroup
{...props}
className={composeTailwindRenderProps(props.className, groupBox)}
/>
<RadioGroupVariantContext.Provider
value={
variant === "card"
? {
variant,
orientation,
labelPlacement,
compact,
}
: {
variant,
orientation,
labelPlacement,
}
}
>
<RACRadioGroup
{...props}
orientation={orientation}
className={composeTailwindRenderProps(props.className, [
groupBox,
variant === "segment" && [
orientation === "vertical" && ["items-start"],
"[--segment-padding:--spacing(0.5)]",
"[--segment-radius:var(--radius-lg)]",
],
])}
>
{children}
</RACRadioGroup>
</RadioGroupVariantContext.Provider>
);
}
export function Radios({
className,
...props
}: React.JSX.IntrinsicElements['div']) {
}: React.JSX.IntrinsicElements["div"]) {
const { variant, orientation, compact } = useRadioGroupVariantContext();
return (
<div
data-ui="box"
className={twMerge(
'flex',
'flex-col',
'group-aria-[orientation=horizontal]:flex-row',
'group-aria-[orientation=horizontal]:flex-wrap',
// When any radio item has description, apply all `font-medium` to all radio item labels
'has-data-[ui=description]:[&_label]:font-medium',
className,
"flex",
"flex-col",
"gap-y-3",
"has-data-[ui=description]:gap-y-4",
"has-data-[ui=description]:[&_label]:font-medium",
orientation === "horizontal" && ["flex-row flex-wrap gap-x-4 gap-y-2"],
variant === "card" && [
orientation === "horizontal" && [
compact ? "flex-row flex-nowrap" : "flex-col sm:flex-row",
],
compact ? "gap-x-0 gap-y-0" : "gap-x-6, gap-y-6",
],
variant === "segment" && [
"bg-zinc-100 p-0.5 dark:bg-zinc-800",
"rounded-(--segment-radius)",
orientation === "horizontal" && ["min-w-sm"],
],
className
)}
{...props}
/>
@ -44,19 +129,23 @@ export function Radios({
export function RadioField({
className,
...props
}: React.JSX.IntrinsicElements['div']) {
}: React.JSX.IntrinsicElements["div"]) {
const { labelPlacement } = useRadioGroupVariantContext();
return (
<DescriptionProvider>
<div
{...props}
data-ui="field"
className={twMerge(
'group flex flex-col gap-y-1',
'has-data-[label-placement=start]:[&_label]:justify-between',
'has-[label[data-label-placement=start]]:[&_[data-ui=description]:not([class*=pe-])]:pe-16',
'has-[label[data-label-placement=end]]:[&_[data-ui=description]:not([class*=ps-])]:ps-7',
'has-[label[data-disabled]]:**:data-[ui=description]:opacity-50',
className,
"group flex flex-col gap-y-1 has-[label[data-disabled]]:**:data-[ui=description]:opacity-50",
labelPlacement === "start" && [
"[&_[data-ui=description]:not([class*=pe-])]:pe-16 [&_label]:justify-between",
],
labelPlacement === "end" && [
"[&_[data-ui=description]:not([class*=ps-])]:ps-7",
],
className
)}
/>
</DescriptionProvider>
@ -64,13 +153,15 @@ export function RadioField({
}
export interface RadioProps extends RACRadioProps {
labelPlacement?: 'start' | 'end';
radio?: React.ReactElement | ((props: RadioRenderProps) => React.ReactNode);
radio?:
| React.ReactElement
| null
| ((props: Partial<RadioRenderProps>) => React.ReactNode);
render?: never;
}
export interface CustomRenderRadioProps
extends Omit<RACRadioProps, 'children'> {
extends Omit<RACRadioProps, "children"> {
render:
| string
| React.ReactElement
@ -79,8 +170,85 @@ export interface CustomRenderRadioProps
children?: never;
}
function getRadioStyle({
isSelected,
isHovered,
variant,
orientation = "vertical",
compact,
}: Partial<RadioRenderProps> & {
variant: RadioGroupVariant["variant"];
orientation?: RadioGroupVariant["orientation"];
compact?: boolean;
}) {
const style = {
radio: [],
card: [
"flex-1 rounded-lg px-4 py-3 items-start [&>[data-slot=radio]:not([class*=mt-])]:mt-1.5",
"[&_[data-ui=icon]:not([class*=size-])]:w-4",
"[&_[data-ui=icon]:not([class*=size-])]:h-[1lh]",
isSelected
? "[&_[data-ui=icon]:not([class*=text-])]:text-foreground"
: "[&_[data-ui=icon]:not([class*=text-])]:text-muted",
compact
? [
"[&:not(:first-child):not(:last-child)]:rounded-none",
orientation === "horizontal" && [
"first:rounded-e-none",
"last:rounded-s-none",
"border-t border-b",
"not-first:border-e",
"not-last:border-s",
"[&:has(+label[data-selected]:last-child)]:border-e-accent/50",
isSelected && "[&:first-child+label]:border-s-accent/50",
],
orientation === "vertical" && [
"border-s border-e",
"first:rounded-b-none",
"last:rounded-t-none",
"not-first:border-b",
"not-last:border-t",
"[&:has(+label[data-selected]:last-child)]:border-b-accent/50",
isSelected && "[&:first-child+label]:border-t-accent/50",
],
isSelected && [
"bg-[color-mix(in_oklab,_var(--accent)_5%,_white)]",
"dark:bg-[color-mix(in_oklab,_var(--accent)_25%,_black)] border-accent/50",
],
]
: [
"ring ring-border",
isSelected && ["ring-ring", "ring-inset", "ring-1"],
],
],
segment: [
"flex",
"justify-center",
"items-center",
"flex-1 text-center font-medium rounded-[calc(var(--segment-radius)-var(--segment-padding))]",
"transition-all ease-in-out",
"[&_[data-ui=icon]:not([class*=size-])]:size-4",
isSelected && [
"bg-white dark:bg-zinc-600",
"shadow-sm dark:shadow-none",
"ring ring-zinc-950/10",
],
!isSelected && !isHovered && "text-muted",
orientation === "horizontal" && ["px-4 py-1"],
orientation === "vertical" && ["p-2"],
],
};
return style[variant ?? "radio"];
}
export function Radio(props: RadioProps | CustomRenderRadioProps) {
const descriptionContext = React.useContext(DescriptionContext);
const { variant, orientation, labelPlacement, compact } =
useRadioGroupVariantContext();
if (props.render !== undefined) {
const { render, ...restProps } = props;
@ -88,17 +256,23 @@ export function Radio(props: RadioProps | CustomRenderRadioProps) {
return (
<RACRadio
{...restProps}
aria-describedby={descriptionContext?.['aria-describedby']}
aria-describedby={descriptionContext?.["aria-describedby"]}
className={composeRenderProps(
props.className,
(className, { isDisabled, isFocusVisible }) =>
(className, renderProps) =>
twMerge(
'group text-base/6 sm:text-sm/6',
isDisabled && 'opacity-50',
isFocusVisible &&
'outline-ring outline outline-2 outline-offset-2',
className,
),
"group text-base/6 sm:text-sm/6",
renderProps.isDisabled && "opacity-50",
renderProps.isFocusVisible &&
"outline-ring outline-2 outline-offset-3",
getRadioStyle({
variant,
orientation,
compact,
...renderProps,
}),
className
)
)}
>
{render}
@ -106,60 +280,50 @@ export function Radio(props: RadioProps | CustomRenderRadioProps) {
);
}
const { labelPlacement = 'end', radio, ...restProps } = props;
const { radio, ...restProps } = props;
const noRadioToggle = radio === null;
return (
<RACRadio
{...restProps}
aria-describedby={descriptionContext?.['aria-describedby']}
data-label-placement={labelPlacement}
className={composeRenderProps(
props.className,
(className, { isDisabled }) =>
twMerge(
'group flex items-center text-base/6 sm:text-sm/6',
'group-aria-[orientation=horizontal]:text-nowrap',
labelPlacement === 'start' && 'flex-row-reverse justify-between',
isDisabled && 'opacity-50',
className,
),
aria-describedby={descriptionContext?.["aria-describedby"]}
className={composeRenderProps(props.className, (className, renderProps) =>
twMerge(
"group flex items-center text-base/6 sm:text-sm/6",
orientation === "horizontal" && "text-nowrap",
labelPlacement === "start" && "flex-row-reverse justify-between",
getRadioStyle({
variant,
orientation,
compact,
...renderProps,
}),
renderProps.isDisabled && "opacity-50",
noRadioToggle && [
renderProps.isFocusVisible &&
"outline-ring outline-2 outline-offset-2",
],
className
)
)}
>
{(renderProps) => {
return (
<>
<div
slot="radio"
className={twMerge(
'border-input grid shrink-0 place-content-center rounded-full border',
radio ? '' : 'size-4.5 sm:size-4',
labelPlacement === 'end' ? 'me-3' : 'ms-3',
renderProps.isReadOnly && 'opacity-50',
renderProps.isInvalid &&
'border-destructive dark:border-destructive',
renderProps.isSelected && 'border-accent bg-accent',
renderProps.isFocusVisible &&
'outline-ring outline outline-2 outline-offset-2',
)}
>
{radio ? (
typeof radio === 'function' ? (
radio(renderProps)
) : (
radio
)
) : (
<div
className={twMerge(
'rounded-full',
renderProps.isSelected &&
'size-2 bg-[lch(from_var(--color-accent)_calc((49.44_-_l)_*_infinity)_0_0)] sm:size-1.5',
)}
></div>
)}
</div>
{!noRadioToggle && (
<RadioToggle
data-slot="radio"
radio={radio}
renderProps={renderProps}
className={twMerge(
labelPlacement === "end" ? "me-3" : "ms-3",
!radio && "size-4.5 sm:size-4"
)}
/>
)}
{typeof props.children === 'function'
{typeof props.children === "function"
? props.children(renderProps)
: props.children}
</>
@ -168,3 +332,68 @@ export function Radio(props: RadioProps | CustomRenderRadioProps) {
</RACRadio>
);
}
type RadioBoxProps = Partial<RadioRenderProps> & {
radio?: Exclude<RadioProps["radio"], null>;
renderProps?: Partial<RadioRenderProps>;
} & Omit<React.JSX.IntrinsicElements["div"], "children">;
export function RadioToggle({
radio,
renderProps,
className,
...props
}: RadioBoxProps) {
return (
<div
{...props}
data-check-indicator
className={twMerge(
"grid shrink-0 place-content-center rounded-full shadow-sm ring ring-zinc-950/15 dark:shadow-none dark:ring-white/20",
radio ? "" : "size-4",
renderProps?.isReadOnly
? "opacity-50"
: renderProps?.isHovered && "ring-zinc-950/25 dark:ring-white/25",
renderProps?.isSelected
? "bg-accent ring-accent dark:ring-accent"
: "dark:bg-white/5 dark:[--contract:1.1]",
// When it is inside menu item and the item is selected
"in-[&[data-ui=content][data-hovered=true]]:ring-zinc-950/25",
"in-[&[data-ui=content][data-hovered=true]]:dark:ring-white/25",
"in-[&[data-ui=content][data-selected=true]]:bg-accent",
"in-[&[data-ui=content][data-selected=true]]:dark:bg-accent",
"in-[&[data-ui=content][data-selected=true]]:ring-accent",
"in-[&[data-ui=content][data-selected=true]]:dark:ring-accent",
renderProps?.isInvalid && "ring-red-600 dark:ring-red-600",
renderProps?.isFocusVisible &&
"outline-ring outline-2 outline-offset-3",
className
)}
>
{radio && renderProps ? (
typeof radio === "function" ? (
radio(renderProps)
) : (
radio
)
) : (
<div
className={twMerge(
"rounded-full",
renderProps?.isSelected &&
"size-2 bg-white shadow-[0_1px_1px_rgba(0,0,0,0.25)] dark:bg-[lch(from_var(--accent)_calc((49.44_-_l)_*_infinity)_0_0)]",
// when it is inside menu item and the item is selected
"in-[&[data-ui=content][data-selected=true]]:size-2",
"in-[&[data-ui=content][data-selected=true]]:bg-white",
"in-[&[data-ui=content][data-selected=true]]:shadow-[0_1px_1px_rgba(0,0,0,0.25)] dark:bg-[lch(from_var(--accent)_calc((49.44_-_l)_*_infinity)_0_0)]"
)}
></div>
)}
</div>
);
}

View file

@ -33,6 +33,15 @@ export function Select<T extends object>(props: RACSelectProps<T>) {
);
}
export function StatusIcon({ className }: { className: string }) {
return (
<span
data-ui="icon"
className={`size-3 rounded-full border border-solid border-white ${className}`}
/>
);
}
export function SelectButton(props: {
className?: string;
children?: React.ReactNode;