Add bookings.tsx
This commit is contained in:
parent
a897b2d21e
commit
00b1ee61a3
15 changed files with 1243 additions and 184 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
57
ui/src/components/CustomModal.tsx
Normal file
57
ui/src/components/CustomModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
181
ui/src/components/EventDetailsModal.tsx
Normal file
181
ui/src/components/EventDetailsModal.tsx
Normal 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'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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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 />,
|
||||
|
|
|
|||
|
|
@ -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
543
ui/src/pages/bookings.tsx
Normal 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'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'hui
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Event Details Modal */}
|
||||
<EventDetailsModal
|
||||
event={selectedEvent}
|
||||
isOpen={isDetailsModalOpen}
|
||||
onClose={() => {
|
||||
setIsDetailsModalOpen(false);
|
||||
setSelectedEvent(null);
|
||||
}}
|
||||
onEdit={() => selectedEvent && handleEditEvent(selectedEvent)}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
95
ui/src/ui-library/badge/badge.styles.ts
Normal file
95
ui/src/ui-library/badge/badge.styles.ts
Normal 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,
|
||||
];
|
||||
}
|
||||
19
ui/src/ui-library/badge/badge.tsx
Normal file
19
ui/src/ui-library/badge/badge.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
5
ui/src/ui-library/badge/index.ts
Normal file
5
ui/src/ui-library/badge/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export type { BadgeColor } from "./badge.styles";
|
||||
|
||||
export { getBadgeStyles } from "./badge.styles";
|
||||
|
||||
export { Badge } from "./badge";
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue