Add event types management functionality, including database schema, UI components, and hooks for CRUD operations. Introduce EventTypeModal for creating and editing event types, and integrate with the navigation bar and event types page for seamless user experience.

This commit is contained in:
Arthur Belleville 2025-09-21 22:27:21 +02:00
parent 00b1ee61a3
commit 27dc530b1c
No known key found for this signature in database
13 changed files with 1016 additions and 104 deletions

View file

@ -138,6 +138,36 @@ export type Database = {
}
Relationships: []
}
event_types: {
Row: {
config: Json
created_at: string | null
deleted_at: string | null
id: string
is_active: boolean
updated_at: string | null
user_id: string
}
Insert: {
config?: Json
created_at?: string | null
deleted_at?: string | null
id?: string
is_active?: boolean
updated_at?: string | null
user_id: string
}
Update: {
config?: Json
created_at?: string | null
deleted_at?: string | null
id?: string
is_active?: boolean
updated_at?: string | null
user_id?: string
}
Relationships: []
}
events: {
Row: {
created_at: string | null

View file

@ -0,0 +1,100 @@
-- Create event_types table to store event type configurations as JSONB
CREATE TABLE IF NOT EXISTS event_types (
id TEXT PRIMARY KEY DEFAULT generate_random_string(24),
user_id UUID NOT NULL,
config JSONB NOT NULL DEFAULT '{}'::jsonb,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP WITH TIME ZONE DEFAULT NULL,
-- Foreign key constraint to users table (auth.users)
CONSTRAINT fk_event_types_user_id
FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE CASCADE
);
-- Create indexes for performance
CREATE INDEX IF NOT EXISTS idx_event_types_user_id ON event_types(user_id);
CREATE INDEX IF NOT EXISTS idx_event_types_is_active ON event_types(is_active);
CREATE INDEX IF NOT EXISTS idx_event_types_deleted_at ON event_types(deleted_at);
-- Create GIN index for JSONB config column for efficient querying
CREATE INDEX IF NOT EXISTS idx_event_types_config_gin ON event_types USING GIN (config);
-- Add constraint to ensure config JSONB has required fields
ALTER TABLE event_types ADD CONSTRAINT event_types_config_check
CHECK (
config ? 'name' AND
(config->>'name')::text <> '' AND
config ? 'duration' AND
(config->>'duration')::int > 0
);
-- Create function to update updated_at timestamp
CREATE OR REPLACE FUNCTION update_event_types_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ language 'plpgsql';
-- Create trigger to automatically update updated_at
CREATE TRIGGER update_event_types_updated_at
BEFORE UPDATE ON event_types
FOR EACH ROW
EXECUTE FUNCTION update_event_types_updated_at();
-- Enable Row Level Security
ALTER TABLE event_types ENABLE ROW LEVEL SECURITY;
-- Policy to allow users to view their own event types
CREATE POLICY "Users can view their own event types" ON event_types
FOR SELECT
TO authenticated
USING (user_id = auth.uid());
-- Policy to allow users to insert their own event types
CREATE POLICY "Users can insert their own event types" ON event_types
FOR INSERT
TO authenticated
WITH CHECK (user_id = auth.uid());
-- Policy to allow users to update their own event types
CREATE POLICY "Users can update their own event types" ON event_types
FOR UPDATE
TO authenticated
USING (user_id = auth.uid())
WITH CHECK (user_id = auth.uid());
-- Policy to allow users to delete their own event types (soft delete)
CREATE POLICY "Users can delete their own event types" ON event_types
FOR UPDATE
TO authenticated
USING (user_id = auth.uid() AND deleted_at IS NULL)
WITH CHECK (user_id = auth.uid());
-- Add helpful comments
COMMENT ON TABLE event_types IS
'Event type configurations stored as JSONB with Row Level Security';
COMMENT ON COLUMN event_types.id IS
'Primary key: random 24-character alphanumeric string';
COMMENT ON COLUMN event_types.user_id IS
'Foreign key reference to auth.users.id';
COMMENT ON COLUMN event_types.config IS
'JSONB configuration containing: name (required), description (optional), duration (required), bufferTime, maxBookingsPerDay, requiresApproval, price, location, minAdvanceBooking, etc.';
COMMENT ON COLUMN event_types.is_active IS
'Whether this event type is active and available for booking';
COMMENT ON COLUMN event_types.created_at IS
'Timestamp when the event type was created';
COMMENT ON COLUMN event_types.updated_at IS
'Timestamp when the event type was last updated (auto-updated by trigger)';
COMMENT ON COLUMN event_types.deleted_at IS
'Timestamp for soft deletion (NULL means not deleted)';

View file

@ -4,6 +4,7 @@ interface CustomModalProps {
onClose: () => void;
title: string;
children: React.ReactNode;
width?: "sm" | "md" | "lg" | "xl" | "2xl" | "full" | "auto";
}
export function CustomModal({
@ -11,9 +12,31 @@ export function CustomModal({
onClose,
title,
children,
width = "md",
}: CustomModalProps) {
if (!isOpen) return null;
const getWidthClasses = () => {
switch (width) {
case "sm":
return "w-full max-w-sm";
case "md":
return "w-full max-w-md";
case "lg":
return "w-full max-w-lg";
case "xl":
return "w-full max-w-xl";
case "2xl":
return "w-full max-w-2xl";
case "full":
return "w-full max-w-full mx-4";
case "auto":
return "w-auto min-w-80 max-w-[90vw]";
default:
return "w-full max-w-md";
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
@ -23,9 +46,11 @@ export function CustomModal({
/>
{/* 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">
<div
className={`relative bg-white dark:bg-gray-800 rounded-xl shadow-2xl border border-gray-200 dark:border-gray-700 ${getWidthClasses()} mx-4 max-h-[90vh] flex flex-col`}
>
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
{title}
</h2>
@ -50,7 +75,7 @@ export function CustomModal({
</div>
{/* Content */}
<div className="p-6">{children}</div>
<div className="p-6 overflow-y-auto flex-1">{children}</div>
</div>
</div>
);

View file

@ -1,14 +1,10 @@
import { EventAndTablo } from "@ui/types/events.types";
import {
Dialog,
DialogHeader,
DialogBody,
DialogCloseButton,
} from "@ui/ui-library/dialog";
import { DialogBody } 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";
import { twMerge } from "tailwind-merge";
interface EventDetailsModalProps {
event: EventAndTablo | null;
@ -21,7 +17,6 @@ export const EventDetailsModal = ({
event,
isOpen,
onClose,
onEdit,
}: EventDetailsModalProps) => {
if (!event) return null;
@ -41,10 +36,10 @@ export const EventDetailsModal = ({
if (event.start_time) {
// Remove seconds from time (HH:MM:SS -> HH:MM)
const startTime = event.start_time.substring(0, 5);
formatted += ` à ${startTime}`;
formatted += ` de ${startTime}`;
if (event.end_time) {
const endTime = event.end_time.substring(0, 5);
formatted += ` - ${endTime}`;
formatted += ` à ${endTime}`;
}
}
@ -83,99 +78,69 @@ export const EventDetailsModal = ({
}
};
console.log(event.tablo_color);
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>
<DialogBody className="space-y-6">
<div className="flex items-start space-x-3">
{getEventStatusBadge(event)}
</div>
{/* 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>
<DialogCloseButton />
</DialogHeader>
</div>
<DialogBody className="space-y-6">
{/* Date and Time */}
{/* Tablo */}
{event.tablo_name && (
<div className="flex items-start space-x-3">
<CalendarIcon className="w-5 h-5 text-gray-400 mt-0.5" />
<div
className={twMerge("w-5 h-5 rounded mt-0.5", event.tablo_color)}
/>
<div>
<Strong className="text-sm text-gray-900 dark:text-gray-100">
Date et heure
Tableau
</Strong>
<Text className="text-gray-600 dark:text-gray-300">
{formatEventDateTime(event)}
{event.tablo_name}
</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>}
{/* 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>
</DialogBody>
)}
</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>
{/* 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>
</div>
</CustomModal>
);
};

View file

@ -0,0 +1,263 @@
import { Button } from "@ui/ui-library/button";
import { CustomModal } from "./CustomModal";
import {
Label,
TextField,
TextArea,
Description,
Input,
} from "@ui/ui-library/field";
import { NumberField, NumberInput } from "@ui/ui-library/number-field";
import {
Select,
SelectButton,
SelectListBox,
SelectListItem,
SelectPopover,
} from "@ui/ui-library/select";
import { EventType } from "@ui/hooks/event-types";
export function EventTypeModal({
isModalOpen,
setIsModalOpen,
editingEventType,
formData,
setFormData,
handleSaveEventType,
}: {
isModalOpen: boolean;
setIsModalOpen: (isOpen: boolean) => void;
editingEventType: EventType | null;
formData: EventType;
setFormData: (formData: EventType) => void;
handleSaveEventType: () => void;
}) {
return (
<CustomModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
title={
editingEventType
? "Modifier le type d'événement"
: "Nouveau type d'événement"
}
width="xl"
>
<div className="space-y-6">
{/* Basic Information Section */}
<div className="space-y-2">
<TextField
value={formData.name || ""}
onChange={(value) => setFormData({ ...formData, name: value })}
isRequired
>
<Label requiredHint>Nom du type d&apos;événement</Label>
<Input type="text" />
</TextField>
<TextField>
<Label>Description</Label>
<TextArea
value={formData.description || ""}
onChange={(e) =>
setFormData({ ...formData, description: e.target.value })
}
rows={2}
required
placeholder="Décrivez ce type d'événement et son objectif..."
/>
</TextField>
</div>
{/* Timing Configuration Section */}
<div className="space-y-2">
<h4 className="text-lg font-medium text-gray-900 dark:text-white border-b border-gray-200 dark:border-gray-700 pb-2">
Configuration des horaires
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<NumberField
value={formData.duration || 60}
onChange={(value) =>
setFormData({ ...formData, duration: value })
}
minValue={15}
maxValue={480}
step={15}
>
<Label requiredHint>Durée (minutes)</Label>
<NumberInput />
</NumberField>
<NumberField
value={formData.bufferTime || 0}
onChange={(value) =>
setFormData({ ...formData, bufferTime: value })
}
minValue={0}
maxValue={60}
step={5}
>
<Label>Temps de battement (minutes)</Label>
<NumberInput />
<Description>
Temps de battement avant et après l&apos;événement
</Description>
</NumberField>
</div>
</div>
{/* Booking Limits Section */}
<div className="space-y-2">
<h4 className="text-lg font-medium text-gray-900 dark:text-white border-b border-gray-200 dark:border-gray-700 pb-2">
Limites de réservation
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<NumberField
value={formData.maxBookingsPerDay || 8}
onChange={(value) =>
setFormData({ ...formData, maxBookingsPerDay: value })
}
minValue={1}
maxValue={50}
>
<Label>Maximum par jour</Label>
<NumberInput />
</NumberField>
<div className="flex flex-col gap-2">
<Label>Réservation à l&apos;avance (heures)</Label>
<div className="flex flex-row gap-2">
<NumberField
value={formData.minAdvanceBooking?.value || 0}
onChange={(value) =>
setFormData({
...formData,
minAdvanceBooking: {
value,
unit: formData.minAdvanceBooking?.unit || "minutes",
},
})
}
minValue={0}
maxValue={168}
>
<NumberInput />
</NumberField>
<Select
selectedKey={String(
formData.minAdvanceBooking?.unit || "minutes"
)}
onSelectionChange={(key) => {
console.log(key);
setFormData({
...formData,
minAdvanceBooking: {
value: formData.minAdvanceBooking?.value || 0,
unit: key as "minutes" | "hours" | "days",
},
});
}}
placeholder="..."
className="min-w-[110px]"
aria-label="Délai minimum pour réserver"
>
<SelectButton />
<SelectPopover className="w-36">
<SelectListBox>
<SelectListItem id="minutes">minutes</SelectListItem>
<SelectListItem id="hours">heures</SelectListItem>
<SelectListItem id="days">jours</SelectListItem>
</SelectListBox>
</SelectPopover>
</Select>
</div>
<Description>Délai minimum pour réserver</Description>
</div>
</div>
</div>
{/* Pricing Section
<div className="space-y-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-white border-b border-gray-200 dark:border-gray-700 pb-2">
Tarification (optionnel)
</h3>
<NumberField
value={formData.price || 0}
onChange={(value) => setFormData({ ...formData, price: value })}
minValue={0}
maxValue={10000}
step={5}
formatOptions={{
style: "currency",
currency: "EUR",
}}
>
<Label>Prix ()</Label>
<Description>
Prix de ce type d&apos;événement. Laissez à 0 pour gratuit.
</Description>
</NumberField>
</div> */}
{/* Settings Section */}
{/* <div className="space-y-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-white border-b border-gray-200 dark:border-gray-700 pb-2">
Paramètres
</h3>
<div className="space-y-4">
<div className="flex items-start space-x-3">
<Checkbox
isSelected={formData.isActive || false}
onChange={(isActive) => setFormData({ ...formData, isActive })}
>
<div>
<div className="font-medium">Type d&apos;événement actif</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
Les clients peuvent réserver ce type d&apos;événement
</div>
</div>
</Checkbox>
</div>
<div className="flex items-start space-x-3">
<Checkbox
isSelected={formData.requiresApproval || false}
onChange={(requiresApproval) =>
setFormData({ ...formData, requiresApproval })
}
>
<div>
<div className="font-medium">
Nécessite une approbation manuelle
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
Vous devrez approuver chaque réservation avant confirmation
</div>
</div>
</Checkbox>
</div>
</div>
</div> */}
{/* Action Buttons */}
<div className="flex justify-end gap-3 pt-3 border-t border-gray-200 dark:border-gray-700">
<Button variant="outline" onPress={() => setIsModalOpen(false)}>
Annuler
</Button>
<Button
variant="solid"
onPress={handleSaveEventType}
className="[--btn-bg:var(--color-green-800)]"
isDisabled={!formData.name?.trim() || !formData.duration}
>
{editingEventType ? "Modifier" : "Créer"}
</Button>
</div>
</div>
</CustomModal>
);
}

View file

@ -13,6 +13,7 @@ import {
Kanban,
CalendarIcon,
CalendarCheckIcon,
ListCheckIcon,
} from "lucide-react";
import { Link as RouterLink, useLocation } from "react-router-dom";
import { Separator } from "react-aria-components";
@ -271,9 +272,9 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
isDisabled: true,
},
{
path: "/planning",
label: "Planning",
icon: <SquareKanban className="w-5 h-5" />,
path: "/event-types",
label: "Types d'événements",
icon: <ListCheckIcon className="w-5 h-5" />,
},
{
path: "/availabilities",
@ -298,6 +299,12 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
isDisabled: true,
},
{ isHorizontalBar: true },
{
path: "/planning",
label: "Planning",
icon: <SquareKanban className="w-5 h-5" />,
},
{ isHorizontalBar: true },
{
path: "/",
label: "Tableaux",
@ -315,10 +322,10 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
role="list"
className={twMerge("grid py-3", isCollapsed ? "pl-2.5 pr-3" : "")}
>
{navItems.map((item) => {
{navItems.map((item, index) => {
if ("isHorizontalBar" in item) {
return (
<li key="horizontal-bar" className="my-2">
<li key={index} className="my-2">
<Separator className="border-gray-300/20" />
</li>
);

156
ui/src/hooks/event-types.ts Normal file
View file

@ -0,0 +1,156 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { supabase } from "./auth";
import { useSession } from "@ui/contexts/SessionContext";
import { Database } from "@ui/types/database.types";
import { queryClient } from "@ui/lib/api";
import { useMemo } from "react";
export type EventType = {
id: string;
name: string;
description: string;
duration: number; // in minutes
isActive: boolean;
bufferTime?: number; // buffer time before/after in minutes
maxBookingsPerDay?: number;
requiresApproval: boolean;
price?: number; // optional price
location?: string; // optional location
minAdvanceBooking?: {
value: number;
unit: "minutes" | "hours" | "days";
}; // minimum hours in advance
};
const QUERY_KEY = ["event-types"];
export function useEventTypes() {
const { session } = useSession();
const { data: eventTypesData, isLoading } = useQuery<
Database["public"]["Tables"]["event_types"]["Row"][]
>({
queryKey: QUERY_KEY,
queryFn: async () => {
const { data, error } = await supabase
.from("event_types")
.select("*")
.eq("user_id", session?.user.id)
.is("deleted_at", null);
if (error) throw error;
return data;
},
enabled: !!session?.user.id,
});
const { mutate: addEventType } = useMutation<
void,
Error,
{
eventType: EventType;
}
>({
mutationFn: async ({ eventType }: { eventType: EventType }) => {
const { error } = await supabase.from("event_types").insert({
config: eventType,
user_id: session?.user.id,
});
if (error) throw error;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
},
});
const { mutate: updateEventType } = useMutation<
void,
Error,
{
id: string;
eventType: EventType;
}
>({
mutationFn: async ({
id,
eventType,
}: {
id: string;
eventType: EventType;
}) => {
const { error } = await supabase
.from("event_types")
.update({
config: eventType,
is_active: eventType.isActive,
})
.eq("id", id);
if (error) throw error;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
},
});
const { mutate: toggleEventType } = useMutation<
void,
Error,
{
id: string;
isActive: boolean;
}
>({
mutationFn: async ({ id, isActive }: { id: string; isActive: boolean }) => {
const { error } = await supabase
.from("event_types")
.update({
is_active: isActive,
})
.eq("id", id);
if (error) throw error;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
},
});
const { mutate: deleteEventType } = useMutation<
void,
Error,
{
id: string;
}
>({
mutationFn: async ({ id }: { id: string }) => {
const { error } = await supabase
.from("event_types")
.update({ deleted_at: new Date().toISOString() })
.eq("id", id);
if (error) throw error;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
},
});
const eventTypes = useMemo(() => {
return (
eventTypesData?.map((eventType) => {
const eventTypeConfig = eventType.config as EventType;
return {
...eventTypeConfig,
isActive: eventType.is_active,
id: eventType.id,
};
}) ?? []
);
}, [eventTypesData]);
return {
isLoading,
eventTypes,
addEventType,
updateEventType,
toggleEventType,
deleteEventType,
};
}

View file

@ -21,6 +21,7 @@ import { FeedbackPage } from "@ui/pages/feedback";
import { SupportPage } from "@ui/pages/support";
import { AvailabilitiesPage } from "@ui/pages/availabilities";
import { BookingsPage } from "@ui/pages/bookings";
import { EventTypesPage } from "@ui/pages/event-types-page";
export const routes: RouteObject[] = [
// Protected routes
@ -84,6 +85,10 @@ export const routes: RouteObject[] = [
element: <BookingsPage />,
children: [{ index: true }, { path: ":tablo_id" }],
},
{
path: "event-types",
element: <EventTypesPage />,
},
{
path: "feedback",
element: <FeedbackPage />,

View file

@ -22,6 +22,8 @@ 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";
import { twMerge } from "tailwind-merge";
import { getTextColorFromTabloColor } from "@ui/utils/helpers";
type BookingStatus = "all" | "upcoming" | "past";
@ -339,13 +341,11 @@ export const BookingsPage = () => {
</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",
}}
className={twMerge(
"inline-flex items-center px-2 py-1 rounded-md text-xs font-medium",
event.tablo_color,
getTextColorFromTabloColor(event.tablo_color)
)}
>
{event.tablo_name}
</span>
@ -360,13 +360,6 @@ export const BookingsPage = () => {
</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"

View file

@ -0,0 +1,213 @@
import { useState } from "react";
import { Strong, Text } from "@ui/ui-library/text";
import { Button, ToggleButton } from "@ui/ui-library/button";
import { PlusIcon, EditIcon, TrashIcon, CheckIcon, XIcon } from "lucide-react";
import { toast } from "@ui/ui-library/toast/toast-queue";
import { EventTypeModal } from "@ui/components/EventTypeModal";
import { EventType, useEventTypes } from "@ui/hooks/event-types";
export function EventTypesPage() {
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingEventType, setEditingEventType] = useState<EventType | null>(
null
);
const [formData, setFormData] = useState<Partial<EventType>>({
name: "",
description: "",
duration: 60,
isActive: true,
bufferTime: 15,
maxBookingsPerDay: 8,
requiresApproval: false,
});
const {
eventTypes: eventTypesData,
addEventType,
updateEventType,
toggleEventType,
deleteEventType,
} = useEventTypes();
const handleCreateEventType = () => {
setEditingEventType(null);
setFormData({
name: "",
description: "",
duration: 60,
isActive: true,
bufferTime: 15,
maxBookingsPerDay: 8,
requiresApproval: false,
});
setIsModalOpen(true);
};
const handleEditEventType = (eventType: EventType) => {
setEditingEventType(eventType);
setFormData(eventType as EventType);
setIsModalOpen(true);
};
const handleSaveEventType = () => {
if (!formData.name) {
toast.add({
title: "Erreur",
description: "Veuillez remplir tous les champs obligatoires",
type: "error",
});
return;
}
if (editingEventType) {
updateEventType({
id: editingEventType.id,
eventType: formData as EventType,
});
} else {
addEventType({ eventType: formData as EventType });
}
setIsModalOpen(false);
setEditingEventType(null);
};
return (
<div className="h-full flex flex-col p-4">
<div className="flex justify-between items-start mb-6">
<div>
<h2 className="text-2xl font-bold">Types d&apos;événements</h2>
<Strong className="text-gray-500 mt-2 text-xl">
Configurez les différents types d&apos;événements que vous proposez
</Strong>
</div>
<Button
size="lg"
variant="solid"
className="[--btn-bg:var(--color-blue-800)]"
onPress={handleCreateEventType}
>
<PlusIcon /> Nouveau type
</Button>
</div>
<div className="flex-1 overflow-auto">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{eventTypesData?.map((eventType) => (
<div
key={eventType.id}
className={`bg-white dark:bg-gray-800 rounded-lg shadow p-6 border ${
eventType.isActive ? "opacity-100" : "opacity-60"
}`}
>
<div className="flex justify-between items-start mb-4">
<div className="flex items-center gap-3">
<h3 className="text-lg font-semibold">{eventType.name}</h3>
</div>
<div className="flex gap-2">
<Button
variant="plain"
isIconOnly
onPress={() => handleEditEventType(eventType as EventType)}
className="text-gray-500 hover:text-blue-600"
>
<EditIcon className="w-4 h-4" />
</Button>
<Button
variant="plain"
isIconOnly
onPress={() => deleteEventType({ id: eventType.id })}
className="text-gray-500 hover:text-red-600"
>
<TrashIcon className="w-4 h-4" />
</Button>
</div>
</div>
<Text className="text-gray-600 dark:text-gray-400 mb-4">
{eventType.description}
</Text>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-500">Durée:</span>
<span className="font-medium">{eventType.duration} min</span>
</div>
{eventType.bufferTime && (
<div className="flex justify-between">
<span className="text-gray-500">Temps de battement:</span>
<span className="font-medium">
{eventType.bufferTime} min
</span>
</div>
)}
{eventType.maxBookingsPerDay && (
<div className="flex justify-between">
<span className="text-gray-500">Max par jour:</span>
<span className="font-medium">
{eventType.maxBookingsPerDay}
</span>
</div>
)}
{eventType.minAdvanceBooking && (
<div className="flex justify-between">
<span className="text-gray-500">
Réservation à l&apos;avance:
</span>
<span className="font-medium">
{eventType.minAdvanceBooking.value}{" "}
{eventType.minAdvanceBooking.unit === "minutes"
? "min"
: eventType.minAdvanceBooking.unit === "hours"
? "h"
: "j"}
</span>
</div>
)}
<div className="flex justify-between items-center pt-2 border-t border-gray-200 dark:border-gray-700">
<span className="text-gray-500">Statut:</span>
<ToggleButton
isSelected={eventType.isActive}
onChange={() =>
toggleEventType({
id: eventType.id,
isActive: !eventType.isActive,
})
}
className="text-sm"
>
{eventType.isActive ? <CheckIcon /> : <XIcon />}
{eventType.isActive ? "Actif" : "Inactif"}
</ToggleButton>
</div>
</div>
</div>
))}
</div>
{eventTypesData?.length === 0 && (
<div className="text-center py-12">
<Text className="text-gray-500 mb-4">
Aucun type d&apos;événement configuré
</Text>
<Button
variant="solid"
onPress={handleCreateEventType}
className="[--btn-bg:var(--color-blue-800)]"
>
<PlusIcon /> Créer votre premier type
</Button>
</div>
)}
</div>
<EventTypeModal
isModalOpen={isModalOpen}
setIsModalOpen={setIsModalOpen}
editingEventType={editingEventType}
formData={formData as EventType}
setFormData={setFormData}
handleSaveEventType={handleSaveEventType}
/>
</div>
);
}

View file

@ -138,6 +138,36 @@ export type Database = {
}
Relationships: []
}
event_types: {
Row: {
config: Json
created_at: string | null
deleted_at: string | null
id: string
is_active: boolean
updated_at: string | null
user_id: string
}
Insert: {
config?: Json
created_at?: string | null
deleted_at?: string | null
id?: string
is_active?: boolean
updated_at?: string | null
user_id: string
}
Update: {
config?: Json
created_at?: string | null
deleted_at?: string | null
id?: string
is_active?: boolean
updated_at?: string | null
user_id?: string
}
Relationships: []
}
events: {
Row: {
created_at: string | null

View file

@ -270,3 +270,98 @@ export const parseICSFile = (icsContent: string): ParsedICSEvent[] => {
return events;
};
export const getTextColorFromTabloColor = (tabloColor: string): string => {
// Map of tablo background colors to their corresponding text colors
const colorMap: Record<string, string> = {
// Blue variants
"bg-blue-100": "text-blue-800",
"bg-blue-200": "text-blue-900",
"bg-blue-300": "text-blue-900",
"bg-blue-400": "text-white",
"bg-blue-500": "text-white",
"bg-blue-600": "text-white",
"bg-blue-700": "text-white",
"bg-blue-800": "text-white",
"bg-blue-900": "text-white",
// Green variants
"bg-green-100": "text-green-800",
"bg-green-200": "text-green-900",
"bg-green-300": "text-green-900",
"bg-green-400": "text-white",
"bg-green-500": "text-white",
"bg-green-600": "text-white",
"bg-green-700": "text-white",
"bg-green-800": "text-white",
"bg-green-900": "text-white",
// Red variants
"bg-red-100": "text-red-800",
"bg-red-200": "text-red-900",
"bg-red-300": "text-red-900",
"bg-red-400": "text-white",
"bg-red-500": "text-white",
"bg-red-600": "text-white",
"bg-red-700": "text-white",
"bg-red-800": "text-white",
"bg-red-900": "text-white",
// Yellow variants
"bg-yellow-100": "text-yellow-800",
"bg-yellow-200": "text-yellow-900",
"bg-yellow-300": "text-yellow-900",
"bg-yellow-400": "text-yellow-900",
"bg-yellow-500": "text-white",
"bg-yellow-600": "text-white",
"bg-yellow-700": "text-white",
"bg-yellow-800": "text-white",
"bg-yellow-900": "text-white",
// Purple variants
"bg-purple-100": "text-purple-800",
"bg-purple-200": "text-purple-900",
"bg-purple-300": "text-purple-900",
"bg-purple-400": "text-white",
"bg-purple-500": "text-white",
"bg-purple-600": "text-white",
"bg-purple-700": "text-white",
"bg-purple-800": "text-white",
"bg-purple-900": "text-white",
// Pink variants
"bg-pink-100": "text-pink-800",
"bg-pink-200": "text-pink-900",
"bg-pink-300": "text-pink-900",
"bg-pink-400": "text-white",
"bg-pink-500": "text-white",
"bg-pink-600": "text-white",
"bg-pink-700": "text-white",
"bg-pink-800": "text-white",
"bg-pink-900": "text-white",
// Indigo variants
"bg-indigo-100": "text-indigo-800",
"bg-indigo-200": "text-indigo-900",
"bg-indigo-300": "text-indigo-900",
"bg-indigo-400": "text-white",
"bg-indigo-500": "text-white",
"bg-indigo-600": "text-white",
"bg-indigo-700": "text-white",
"bg-indigo-800": "text-white",
"bg-indigo-900": "text-white",
// Gray variants
"bg-gray-100": "text-gray-800",
"bg-gray-200": "text-gray-900",
"bg-gray-300": "text-gray-900",
"bg-gray-400": "text-white",
"bg-gray-500": "text-white",
"bg-gray-600": "text-white",
"bg-gray-700": "text-white",
"bg-gray-800": "text-white",
"bg-gray-900": "text-white",
};
return colorMap[tabloColor] || "text-gray-900";
};

View file

@ -138,6 +138,36 @@ export type Database = {
}
Relationships: []
}
event_types: {
Row: {
config: Json
created_at: string | null
deleted_at: string | null
id: string
is_active: boolean
updated_at: string | null
user_id: string
}
Insert: {
config?: Json
created_at?: string | null
deleted_at?: string | null
id?: string
is_active?: boolean
updated_at?: string | null
user_id: string
}
Update: {
config?: Json
created_at?: string | null
deleted_at?: string | null
id?: string
is_active?: boolean
updated_at?: string | null
user_id?: string
}
Relationships: []
}
events: {
Row: {
created_at: string | null