Add exceptions + viz

This commit is contained in:
Arthur Belleville 2025-09-12 23:46:04 +02:00
parent d03022c21b
commit a897b2d21e
No known key found for this signature in database
11 changed files with 592 additions and 298 deletions

View file

@ -18,6 +18,7 @@ export type Database = {
Row: {
availability_data: Json
created_at: string
exceptions: Json | null
id: number
updated_at: string
user_id: string
@ -25,6 +26,7 @@ export type Database = {
Insert: {
availability_data?: Json
created_at?: string
exceptions?: Json | null
id?: number
updated_at?: string
user_id: string
@ -32,6 +34,7 @@ export type Database = {
Update: {
availability_data?: Json
created_at?: string
exceptions?: Json | null
id?: number
updated_at?: string
user_id?: string

View file

@ -67,3 +67,10 @@ ALTER TABLE availabilities RENAME COLUMN availabilities TO availability_data;
-- Update the comment for the renamed column
COMMENT ON COLUMN availabilities.availability_data IS
'JSONB object containing availability settings for each day (0-6, where 0 is Monday). Each day has enabled status and time ranges.';
-- Add exceptions column for date-specific availability overrides
ALTER TABLE availabilities ADD COLUMN exceptions JSONB DEFAULT '[]';
-- Add comment for the exceptions column
COMMENT ON COLUMN availabilities.exceptions IS
'JSONB object containing date-specific availability exceptions that override regular availability settings. Keys are ISO date strings (YYYY-MM-DD), values contain exception type and optional time ranges.';

View file

@ -29,7 +29,6 @@ interface AvailabilityCardProps {
) => void;
}
const MINUTES_IN_DAY = 24 * 60;
const MAX_END_TIME_MINUTES = 23 * 60 + 30; // 23:30
const DAYS_OF_WEEK_DISPLAY = [
@ -55,96 +54,6 @@ function minutesToTime(minutes: number): string {
.padStart(2, "0")}`;
}
// TimeRangeSlider component for visualizing time ranges
function TimeRangeSlider({ timeRanges }: { timeRanges: TimeRange[] }) {
const sortedRanges = [...timeRanges].sort(
(a, b) => timeToMinutes(a.start) - timeToMinutes(b.start)
);
// Generate hour markers (every 2 hours from 6 AM to 10 PM)
const hourMarkers = [];
for (let hour = 6; hour <= 22; hour += 2) {
hourMarkers.push({
time: `${hour.toString().padStart(2, "0")}:00`,
minutes: hour * 60,
label: `${hour}h`,
});
}
const getPositionPercentage = (minutes: number) => {
// Map 0-1440 minutes (24 hours) to 0-100%
return (minutes / MINUTES_IN_DAY) * 100;
};
return (
<div className="my-2">
<div className="relative h-8 bg-gradient-to-r from-gray-50 via-white to-gray-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
{/* Hour markers - subtle dots */}
{hourMarkers.map((marker) => (
<div
key={marker.time}
className="absolute top-1/2 transform -translate-y-1/2 w-1 h-1 bg-gray-300 dark:bg-gray-600 rounded-full"
style={{ left: `${getPositionPercentage(marker.minutes)}%` }}
/>
))}
{/* Time range bubbles */}
{sortedRanges.map((range, index) => {
const startMinutes = timeToMinutes(range.start);
const endMinutes = timeToMinutes(range.end);
const leftPercent = getPositionPercentage(startMinutes);
const widthPercent = getPositionPercentage(endMinutes - startMinutes);
return (
<div
key={index}
className="absolute top-1/2 transform -translate-y-1/2 h-5 bg-gradient-to-r from-primary/80 via-primary to-primary/80 dark:from-primary/70 dark:via-primary/80 dark:to-primary/70 rounded-xl shadow-md border border-primary/20 dark:border-primary/30 hover:shadow-lg transition-all duration-300 hover:scale-105"
style={{
left: `${leftPercent}%`,
width: `${widthPercent}%`,
minWidth: "20px",
}}
>
{/* Inner glow effect */}
<div className="absolute inset-0 bg-gradient-to-r from-white/30 via-white/10 to-transparent rounded-xl" />
{/* Bubble highlight */}
<div className="absolute top-0.5 left-1 right-1 h-1 bg-white/40 rounded-lg" />
</div>
);
})}
{/* Start and end time markers - elegant */}
<div className="absolute left-2 top-1/2 transform -translate-y-1/2">
<div className="w-1.5 h-1.5 bg-gray-400 dark:bg-gray-500 rounded-full" />
<span className="absolute top-2 left-1/2 transform -translate-x-1/2 text-[9px] text-gray-400 dark:text-gray-500 font-medium">
0h
</span>
</div>
<div className="absolute right-2 top-1/2 transform -translate-y-1/2">
<div className="w-1.5 h-1.5 bg-gray-400 dark:bg-gray-500 rounded-full" />
<span className="absolute top-2 left-1/2 transform -translate-x-1/2 text-[9px] text-gray-400 dark:text-gray-500 font-medium">
24h
</span>
</div>
</div>
{/* Hour labels - refined */}
<div className="relative mt-3 h-4">
{hourMarkers.map((marker) => (
<div
key={marker.time}
className="absolute text-xs font-semibold text-gray-500 dark:text-gray-400 transform -translate-x-1/2"
style={{ left: `${getPositionPercentage(marker.minutes)}%` }}
>
{marker.label}
</div>
))}
</div>
</div>
);
}
export function AvailabilityCard({
day,
enabled,
@ -378,11 +287,6 @@ export function AvailabilityCard({
</Button>
)}
</div>
{/* Time Slider Visualization */}
{enabled && timeRanges.length > 0 && (
<TimeRangeSlider timeRanges={timeRanges} />
)}
</div>
);
}

View file

@ -0,0 +1,127 @@
import { WeeklyAvailability } from "@ui/hooks/availabilities";
import { Text } from "@ui/ui-library/text";
// Check if a time slot is available for a given day
const isTimeSlotAvailable = (
day: number,
timeSlot: string,
draftAvailabilities: WeeklyAvailability
): boolean => {
const dayAvailability = draftAvailabilities[day];
if (!dayAvailability.enabled) return false;
const slotMinutes = timeToMinutes(timeSlot);
return dayAvailability.timeRanges.some((range) => {
const startMinutes = timeToMinutes(range.start);
const endMinutes = timeToMinutes(range.end);
return slotMinutes >= startMinutes && slotMinutes < endMinutes;
});
};
// Helper function to convert time string to minutes
const timeToMinutes = (time: string): number => {
const [hours, minutes] = time.split(":").map(Number);
return hours * 60 + minutes;
};
// Helper function to convert minutes to time string
const minutesToTime = (minutes: number): string => {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return `${hours.toString().padStart(2, "0")}:${mins
.toString()
.padStart(2, "0")}`;
};
// Generate time slots for visualization based on duration
const generateTimeSlots = (intervalMinutes: number = 30) => {
const slots = [];
for (let hour = 0; hour < 24; hour++) {
for (let minute = 0; minute < 60; minute += intervalMinutes) {
slots.push(minutesToTime(hour * 60 + minute));
}
}
return slots;
};
const DAYS_OF_WEEK = [0, 1, 2, 3, 4, 5, 6];
const DAYS_OF_WEEK_DISPLAY = [
"Lundi",
"Mardi",
"Mercredi",
"Jeudi",
"Vendredi",
"Samedi",
"Dimanche",
];
export const AvailabilityVisualization = ({
draftAvailabilities,
slotDurationMinutes = 30,
}: {
draftAvailabilities: WeeklyAvailability;
slotDurationMinutes?: number;
}) => {
const timeSlots = generateTimeSlots(slotDurationMinutes);
return (
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
{/* Weekly Calendar Header */}
<div className="grid grid-cols-8 border-b-2 border-gray-200 dark:border-gray-600">
<div className="p-4 bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-800 dark:to-slate-900 border-r border-gray-200 dark:border-gray-600">
<Text className="font-bold text-sm text-slate-700 dark:text-slate-300">
Heure
</Text>
</div>
{DAYS_OF_WEEK.map((day) => (
<div
key={day}
className="p-4 bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-800 dark:to-slate-900 border-r border-gray-200 dark:border-gray-600 last:border-r-0 text-center"
>
<Text className="font-bold text-sm text-slate-700 dark:text-slate-300">
{DAYS_OF_WEEK_DISPLAY[day]}
</Text>
</div>
))}
</div>
{/* Time Slots */}
<div className="max-h-140 overflow-y-auto">
{timeSlots.map((timeSlot) => {
const timeMinutes = timeToMinutes(timeSlot);
const hour = Math.floor(timeMinutes / 60);
// Only show hours from 6 AM to 11 PM
if (hour < 6 || hour > 23) return null;
return (
<div
key={timeSlot}
className="grid grid-cols-8 border-b border-gray-100 dark:border-gray-700 hover:bg-slate-50/50 dark:hover:bg-slate-800/50 transition-colors duration-150"
>
<div className="p-3 border-r border-gray-200 dark:border-gray-600 bg-gradient-to-r from-slate-50/80 to-slate-100/80 dark:from-slate-800/80 dark:to-slate-900/80">
<Text className="text-xs font-semibold text-slate-600 dark:text-slate-400">
{timeSlot}
</Text>
</div>
{DAYS_OF_WEEK.map((day) => (
<div
key={`${day}-${timeSlot}`}
className="p-3 border-r border-gray-200 dark:border-gray-600 last:border-r-0 min-h-[3rem] flex items-center justify-center bg-gradient-to-br from-white to-slate-50/30 dark:from-gray-800 dark:to-slate-900/30"
>
{isTimeSlotAvailable(day, timeSlot, draftAvailabilities) ? (
<div className="w-full h-8 bg-gradient-to-r from-emerald-400 via-emerald-500 to-emerald-600 dark:from-emerald-500 dark:via-emerald-600 dark:to-emerald-700 rounded-lg shadow-sm border border-emerald-300 dark:border-emerald-600 flex items-center justify-center group hover:shadow-md transition-all duration-200 hover:scale-105">
<div className="w-3 h-3 bg-white/90 rounded-full shadow-sm group-hover:bg-white transition-colors duration-200"></div>
</div>
) : (
<div className="w-full h-8 bg-gray-100 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600 flex items-center justify-center opacity-50">
<div className="w-2 h-2 bg-gray-300 dark:bg-gray-500 rounded-full"></div>
</div>
)}
</div>
))}
</div>
);
})}
</div>
</div>
);
};

View file

@ -3,6 +3,7 @@ import { queryClient } from "@ui/lib/api";
import { supabase } from "@ui/hooks/auth";
import { useSession } from "@ui/contexts/SessionContext";
import { useEffect, useState } from "react";
import { Database } from "@ui/types/database.types";
export type TimeRange = {
start: string;
@ -18,6 +19,18 @@ export type WeeklyAvailability = {
[key: number]: DayAvailability;
};
export type Exception = {
date: string;
} & (
| {
type: "hours";
hours: TimeRange;
}
| {
type: "day";
}
);
const DAYS_OF_WEEK = [0, 1, 2, 3, 4, 5, 6];
export const DEFAULT_AVAILABILITIES: WeeklyAvailability = DAYS_OF_WEEK.reduce(
@ -34,7 +47,9 @@ export const DEFAULT_AVAILABILITIES: WeeklyAvailability = DAYS_OF_WEEK.reduce(
export function useAvailabilities() {
const { session } = useSession();
const { data: availabilities, isLoading } = useQuery<WeeklyAvailability>({
const { data: availabilities, isLoading } = useQuery<
Database["public"]["Tables"]["availabilities"]["Row"]
>({
queryKey: ["availabilities"],
queryFn: async () => {
const { data, error } = await supabase
@ -43,20 +58,35 @@ export function useAvailabilities() {
.eq("user_id", session?.user.id)
.limit(1);
if (error) throw error;
return data?.[0].availability_data as WeeklyAvailability;
return data?.[0] as Database["public"]["Tables"]["availabilities"]["Row"];
},
enabled: !!session?.user.id,
});
console.log("availabilities", availabilities);
const { mutate: updateAvailabilities, isPending: isUpdating } = useMutation({
mutationFn: async (optionalAvailabilities: WeeklyAvailability) => {
const newAvailabilities =
optionalAvailabilities || DEFAULT_AVAILABILITIES;
const { mutate: updateAvailabilities } = useMutation<
void,
Error,
{
updatedAvailabilities: WeeklyAvailability;
newException?: Exception | null;
}
>({
mutationFn: async ({
updatedAvailabilities,
newException = null,
}: {
updatedAvailabilities: WeeklyAvailability;
newException?: Exception | null;
}) => {
const newAvailabilities = updatedAvailabilities || DEFAULT_AVAILABILITIES;
const newExceptions = [
...((availabilities?.exceptions as Exception[] | null) || []),
newException || [],
];
const { error } = await supabase.from("availabilities").upsert(
{
availability_data: newAvailabilities,
exceptions: newExceptions,
user_id: session?.user.id,
},
{
@ -74,16 +104,17 @@ export function useAvailabilities() {
useState<WeeklyAvailability | null>(null);
useEffect(() => {
if (availabilities) {
setDraftAvailabilities(availabilities);
if (availabilities?.availability_data) {
setDraftAvailabilities(
availabilities.availability_data as WeeklyAvailability
);
}
}, [availabilities]);
}, [availabilities?.availability_data]);
return {
isLoading,
updateAvailabilities,
draftAvailabilities: draftAvailabilities || DEFAULT_AVAILABILITIES,
setDraftAvailabilities,
isUpdating,
};
}

View file

@ -1,15 +1,22 @@
import { Strong, Text } from "@ui/ui-library/text";
import { AvailabilityCard } from "@ui/components/AvailabilityCard";
import { Button } from "@ui/ui-library/button";
import { LoadingSpinner } from "@ui/components/LoadingSpinner";
import {
DEFAULT_AVAILABILITIES,
Exception,
useAvailabilities,
WeeklyAvailability,
} from "@ui/hooks/availabilities";
import { toast } from "@ui/ui-library/toast/toast-queue";
import { useState } from "react";
import { Checkbox } from "@ui/ui-library/checkbox";
import { PlusIcon } from "@ui/ui-library/icons";
import { AvailabilityVisualization } from "@ui/components/AvailabilityVisualization";
import { SaveIcon } from "lucide-react";
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 {
@ -81,13 +88,12 @@ interface TimeRange {
}
export function AvailabilitiesPage() {
const {
updateAvailabilities,
isUpdating,
draftAvailabilities,
setDraftAvailabilities,
} = useAvailabilities();
const { updateAvailabilities, draftAvailabilities, setDraftAvailabilities } =
useAvailabilities();
const [activeTab, setActiveTab] = useState<"reglages" | "visualisation">(
"reglages"
);
const [copyModalOpen, setCopyModalOpen] = useState(false);
const [sourceDayData, setSourceDayData] = useState<{
day: number;
@ -95,6 +101,10 @@ export function AvailabilitiesPage() {
timeRanges: TimeRange[];
} | null>(null);
const [selectedDays, setSelectedDays] = useState<number[]>([]);
const [exceptionModalOpen, setExceptionModalOpen] = useState(false);
const [exceptionType, setExceptionType] = useState<"day" | "hours">("day");
const [exceptionDate, setExceptionDate] = useState<Date | null>(null);
const [exceptionHours, setExceptionHours] = useState<TimeRange | null>(null);
const handleCopyToOtherDays = (
sourceDay: number,
@ -135,145 +145,195 @@ export function AvailabilitiesPage() {
<div>
<h2 className="text-2xl font-bold">Disponibilités</h2>
<Strong className="text-gray-500 mt-2 text-xl">
Définissez vos horaires de disponibilité pour chaque jour de la
semaine
{activeTab === "reglages"
? "Définissez vos horaires de disponibilité pour chaque jour de la semaine"
: "Visualisez votre planning hebdomadaire"}
</Strong>
</div>
<div className="flex gap-2">
<Button
size="lg"
onPress={() => {
updateAvailabilities(DEFAULT_AVAILABILITIES);
}}
className="py-1"
>
Horaires de bureau (9h-17h)
</Button>
<Button
size="lg"
variant="outline"
onPress={() => {
const newAvailabilities: WeeklyAvailability = {};
DAYS_OF_WEEK.forEach((day) => {
newAvailabilities[day] = {
enabled: false,
timeRanges: [{ start: "09:00", end: "17:00" }],
};
});
updateAvailabilities(newAvailabilities);
}}
className="py-1"
>
Tout désactiver
</Button>
</div>
{activeTab === "reglages" && (
<div className="flex gap-2">
<Button
size="lg"
variant="solid"
className="[--btn-bg:var(--color-green-800)]"
onPress={() => {
updateAvailabilities(
{
updatedAvailabilities: draftAvailabilities,
newException: null,
},
{
onSuccess: () => {
toast.add({
title: "Succès",
description: "Disponibilités enregistrées avec succès",
type: "success",
});
},
onError: () => {
toast.add({
title: "Erreur",
description:
"Erreur lors de l'enregistrement des disponibilités",
type: "error",
});
},
}
);
}}
>
<SaveIcon /> Enregistrer
</Button>
<Button
variant="solid"
size="lg"
onPress={() => setExceptionModalOpen(true)}
className="[--btn-bg:var(--color-blue-800)]"
>
<PlusIcon /> Ajouter une exception
</Button>
<Button
size="lg"
onPress={() => {
updateAvailabilities({
updatedAvailabilities: DEFAULT_AVAILABILITIES,
});
}}
className="py-1"
>
Horaires de bureau (9h-17h)
</Button>
<Button
size="lg"
variant="outline"
onPress={() => {
const newAvailabilities: WeeklyAvailability = {};
DAYS_OF_WEEK.forEach((day) => {
newAvailabilities[day] = {
enabled: false,
timeRanges: [{ start: "09:00", end: "17:00" }],
};
});
updateAvailabilities({
updatedAvailabilities: newAvailabilities,
});
}}
className="py-1"
>
Tout désactiver
</Button>
</div>
)}
</div>
{/* Tab Navigation */}
<div className="flex border-b border-gray-200 dark:border-gray-700 mb-6">
<button
onClick={() => setActiveTab("reglages")}
className={`px-6 py-3 font-medium text-sm border-b-2 transition-colors ${
activeTab === "reglages"
? "border-primary text-primary"
: "border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
}`}
>
Réglages
</button>
<button
onClick={() => setActiveTab("visualisation")}
className={`px-6 py-3 font-medium text-sm border-b-2 transition-colors ${
activeTab === "visualisation"
? "border-primary text-primary"
: "border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
}`}
>
Visualisation
</button>
</div>
<div className="flex-1 overflow-auto">
<div className="flex items-start">
<div className="flex-1 pr-6 border-r border-gray-200 dark:border-gray-700">
<div className="grid grid-cols-2 gap-4">
{DAYS_OF_WEEK.map((day) => (
<div
key={day}
className="bg-white dark:bg-gray-800 rounded-lg shadow p-2"
>
<div className="space-y-2">
<AvailabilityCard
day={day}
enabled={draftAvailabilities[day].enabled}
onEnabledChange={(enabled) => {
setDraftAvailabilities({
...draftAvailabilities,
[day]: {
...draftAvailabilities[day],
enabled,
},
});
}}
timeRanges={draftAvailabilities[day].timeRanges}
onTimeRangesChange={(ranges) => {
setDraftAvailabilities({
...draftAvailabilities,
[day]: {
...draftAvailabilities[day],
timeRanges: ranges,
},
});
}}
onCopyToOtherDays={handleCopyToOtherDays}
/>
{activeTab === "reglages" && (
<div className="flex items-start">
<div className="flex-1 pr-6 border-r border-gray-200 dark:border-gray-700">
<div className="grid grid-cols-2 gap-4">
{DAYS_OF_WEEK.map((day) => (
<div
key={day}
className="bg-white dark:bg-gray-800 rounded-lg shadow p-2"
>
<div className="space-y-2">
<AvailabilityCard
day={day}
enabled={draftAvailabilities[day].enabled}
onEnabledChange={(enabled) => {
setDraftAvailabilities({
...draftAvailabilities,
[day]: {
...draftAvailabilities[day],
enabled,
},
});
}}
timeRanges={draftAvailabilities[day].timeRanges}
onTimeRangesChange={(ranges) => {
setDraftAvailabilities({
...draftAvailabilities,
[day]: {
...draftAvailabilities[day],
timeRanges: ranges,
},
});
}}
onCopyToOtherDays={handleCopyToOtherDays}
/>
</div>
</div>
))}
</div>
</div>
<div className="w-80 pl-6">
<div className="space-y-6">
<div>
<h3 className="text-xl font-semibold mb-2">Fuseau horaire</h3>
<Text className="text-gray-500">
Vos disponibilités sont affichées dans votre fuseau horaire
local.
</Text>
</div>
))}
</div>
</div>
<div className="w-80 pl-6">
<div className="space-y-6">
<div>
<h3 className="text-xl font-semibold mb-2">Fuseau horaire</h3>
<Text className="text-gray-500">
Vos disponibilités sont affichées dans votre fuseau horaire
local.
</Text>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
<Strong className="block mb-2">Votre fuseau horaire</Strong>
<Text className="text-gray-500">
{Intl.DateTimeFormat().resolvedOptions().timeZone}
</Text>
<Text className="text-sm text-gray-400 mt-2">
{new Date().toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}{" "}
- Heure locale
</Text>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
<Strong className="block mb-2">Votre fuseau horaire</Strong>
<Text className="text-gray-500">
{Intl.DateTimeFormat().resolvedOptions().timeZone}
</Text>
<Text className="text-sm text-gray-400 mt-2">
{new Date().toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}{" "}
- Heure locale
</Text>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
<Strong className="block mb-2">Information</Strong>
<Text className="text-gray-500 text-sm">
Les créneaux horaires seront automatiquement convertis dans le
fuseau horaire de vos clients lorsqu&apos;ils consulteront vos
disponibilités.
</Text>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
<Strong className="block mb-2">Information</Strong>
<Text className="text-gray-500 text-sm">
Les créneaux horaires seront automatiquement convertis dans
le fuseau horaire de vos clients lorsqu&apos;ils
consulteront vos disponibilités.
</Text>
</div>
</div>
</div>
</div>
</div>
</div>
)}
<div className="flex justify-end gap-4 items-center border-t pt-4">
{isUpdating && <LoadingSpinner />}
<Button
size="lg"
variant="solid"
isDisabled={isUpdating}
onPress={() => {
updateAvailabilities(draftAvailabilities, {
onSuccess: () => {
toast.add({
title: "Succès",
description: "Disponibilités enregistrées avec succès",
type: "success",
});
},
onError: () => {
toast.add({
title: "Erreur",
description:
"Erreur lors de l'enregistrement des disponibilités",
type: "error",
});
},
});
}}
>
{isUpdating ? "Enregistrement..." : "Enregistrer les disponibilités"}
</Button>
{activeTab === "visualisation" && (
<AvailabilityVisualization
draftAvailabilities={draftAvailabilities}
slotDurationMinutes={60}
/>
)}
</div>
{/* Copy Modal */}
@ -326,6 +386,126 @@ export function AvailabilitiesPage() {
</div>
</div>
</CustomModal>
{/* Exception Modal */}
<CustomModal
isOpen={exceptionModalOpen}
onClose={() => setExceptionModalOpen(false)}
title="Ajouter une exception"
>
<div className="space-y-4">
<Text className="text-gray-600 dark:text-gray-400">
Définissez une exception pour une date spécifique qui remplacera vos
disponibilités habituelles.
</Text>
<div className="space-y-4">
<DatePicker
minValue={today(getLocalTimeZone())}
className="w-40"
onChange={(value: DateValue | null) => {
if (value === null) {
return;
}
setExceptionDate(value.toDate(getLocalTimeZone()));
}}
>
<Label>Date de l&apos;exception</Label>
<DatePickerInput />
</DatePicker>
<div className="space-y-2">
<RadioGroup
defaultValue="day"
className="max-w-md"
onChange={(value) => {
setExceptionType(value as "day" | "hours");
}}
>
<Label>Type d&apos;exception</Label>
<Radios>
<Radio value="day">Indisponible toute la journée</Radio>
<Radio value="hours">Horaires personnalisés</Radio>
</Radios>
</RadioGroup>
</div>
{/* Custom Time Ranges (shown when custom is selected) */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Créneaux horaires (optionnel)
</label>
<div className="flex items-center gap-2">
<input
type="time"
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-primary focus:border-primary dark:bg-gray-700 dark:text-white"
defaultValue="09:00"
onChange={(value: React.ChangeEvent<HTMLInputElement>) => {
setExceptionHours({
start: value.target.value,
end: exceptionHours?.end || "17:00",
});
}}
/>
<Text className="text-gray-500">à</Text>
<input
type="time"
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-primary focus:border-primary dark:bg-gray-700 dark:text-white"
defaultValue="17:00"
onChange={(value: React.ChangeEvent<HTMLInputElement>) => {
setExceptionHours({
start: exceptionHours?.start || "09:00",
end: value.target.value,
});
}}
/>
</div>
</div>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
<Button
variant="outline"
onPress={() => setExceptionModalOpen(false)}
>
Annuler
</Button>
<Button
variant="solid"
onPress={() => {
setExceptionModalOpen(false);
const exception: Exception =
exceptionType === "hours"
? {
date: exceptionDate?.toISOString() || "",
type: exceptionType,
hours:
exceptionHours ||
({
start: "09:00",
end: "17:00",
} as TimeRange),
}
: {
date: exceptionDate?.toISOString() || "",
type: exceptionType,
};
updateAvailabilities({
updatedAvailabilities: draftAvailabilities,
newException: exception,
});
toast.add({
title: "Succès",
description: "Exception ajoutée avec succès",
type: "success",
});
}}
>
Ajouter l&apos;exception
</Button>
</div>
</div>
</CustomModal>
</div>
);
}

View file

@ -18,6 +18,7 @@ export type Database = {
Row: {
availability_data: Json
created_at: string
exceptions: Json | null
id: number
updated_at: string
user_id: string
@ -25,6 +26,7 @@ export type Database = {
Insert: {
availability_data?: Json
created_at?: string
exceptions?: Json | null
id?: number
updated_at?: string
user_id: string
@ -32,6 +34,7 @@ export type Database = {
Update: {
availability_data?: Json
created_at?: string
exceptions?: Json | null
id?: number
updated_at?: string
user_id?: string

View file

@ -6,9 +6,9 @@ import {
DateSegment,
DateValue,
composeRenderProps,
} from 'react-aria-components';
import { inputField } from './utils';
import { twMerge } from 'tailwind-merge';
} from "react-aria-components";
import { inputField } from "./utils";
import { twMerge } from "tailwind-merge";
export interface DateFieldProps<T extends DateValue>
extends RACDateFieldProps<T> {}
@ -24,37 +24,44 @@ export function DateField<T extends DateValue>(props: DateFieldProps<T>) {
inputField,
// RAC does not set disable to date field when it is disable
// So we have to style disable state for none input
isDisabled && '[&>:not(input)]:opacity-50',
className,
isDisabled && "[&>:not(input)]:opacity-50",
className
);
},
}
)}
/>
);
}
export type DateInputProps = Omit<RACDateInputProps, 'children'>;
export type DateInputProps = Omit<RACDateInputProps, "children">;
export function DateInput(props: DateInputProps) {
return (
<RACDateInput
{...props}
data-ui="control"
className={composeRenderProps(props.className, (className, renderProps) =>
twMerge(
'group flex w-full items-center rounded-md border border-input bg-transparent',
'[&:has([data-disabled=true])]:opacity-50',
'[&:has([data-ui=date-segment][aria-readonly])]:bg-zinc-50',
'dark:[&:has([data-ui=date-segment][aria-readonly])]:bg-white/10',
'block min-w-[150px]',
'text-base/6 sm:text-sm/6',
'px-3',
'py-[calc(--spacing(2.5)-1px)] sm:py-[calc(--spacing(1.5)-1px)]',
renderProps.isInvalid && 'border-destructive',
renderProps.isFocusWithin && 'border-ring ring-1 ring-ring',
className,
),
className={composeRenderProps(
props.className,
(className, { isInvalid, isFocusWithin, isHovered, isDisabled }) =>
twMerge(
"group flex min-w-[150px] items-center",
"w-full rounded-md text-base/6 shadow-sm outline-none sm:text-sm/6 dark:shadow-none",
"px-2.5 py-2.5 sm:py-1.5",
"ring ring-zinc-950/10 dark:ring-white/10",
!isFocusWithin &&
!isDisabled &&
!isInvalid &&
isHovered && [
"[&:not(:has([data-ui=date-segment][aria-readonly]))]:ring-zinc-950/20",
"dark:[&:not(:has([data-ui=date-segment][aria-readonly]))]:ring-white/20",
],
"[&:has([data-disabled=true])]:opacity-50",
"[&:has([data-ui=date-segment][aria-readonly])]:bg-zinc-50",
"dark:[&:has([data-ui=date-segment][aria-readonly])]:bg-white/5",
isInvalid && "ring-red-600 dark:ring-red-600",
isFocusWithin ? "ring-ring dark:ring-ring ring-2" : "",
className
)
)}
>
{(segment) => (
@ -62,9 +69,9 @@ export function DateInput(props: DateInputProps) {
data-ui="date-segment"
segment={segment}
className={twMerge(
'inline rounded-sm px-0.5 caret-transparent outline-0 data-[type=literal]:px-0',
'data-placeholder:italic data-placeholder:text-muted',
'focus:bg-accent focus:text-[lch(from_var(--color-accent)_calc((49.44_-_l)_*_infinity)_0_0)] focus:data-placeholder:text-[lch(from_var(--color-accent)_calc((49.44_-_l)_*_infinity)_0_0)]',
"inline rounded-sm px-0.5 caret-transparent outline-0 data-[type=literal]:px-0",
"data-placeholder:text-muted data-placeholder:italic",
"focus:bg-accent focus:text-[lch(from_var(--accent)_calc((49.44_-_l)_*_infinity)_0_0)] focus:data-placeholder:text-[lch(from_var(--accent)_calc((49.44_-_l)_*_infinity)_0_0)]"
)}
/>
)}

View file

@ -1,4 +1,4 @@
import React from 'react';
import React from "react";
import {
DatePicker as RACDatePicker,
DatePickerProps as RACDatePickerProps,
@ -7,15 +7,15 @@ import {
useLocale,
Group,
composeRenderProps,
} from 'react-aria-components';
import { Button } from './button';
import { Calendar, YearRange } from './calendar';
import { DateInput, DateInputProps } from './date-field';
import { Dialog } from './dialog';
import { Popover } from './popover';
import { inputField } from './utils';
import { twMerge } from 'tailwind-merge';
import { CalendarIcon } from './icons';
} from "react-aria-components";
import { Button } from "./button";
import { Calendar, YearRange } from "./calendar";
import { DateInput, DateInputProps } from "./date-field";
import { Dialog } from "./dialog";
import { Popover } from "./popover";
import { inputField } from "./utils";
import { twMerge } from "tailwind-merge";
import { CalendarIcon } from "./icons/outline/calendar";
export interface DatePickerProps<T extends DateValue>
extends RACDatePickerProps<T> {}
@ -41,22 +41,21 @@ export function DatePickerInput({
data-ui="control"
{...props}
className={[
'group',
'grid w-auto min-w-52',
'grid-cols-[1fr_calc(theme(size.5)+20px)]',
'sm:grid-cols-[1fr_calc(theme(size.4)+20px)]',
].join(' ')}
"group",
"grid w-auto min-w-52",
"grid-cols-[1fr_auto]",
].join(" ")}
>
<DateInput
{...props}
className={composeRenderProps(props.className, (className) =>
twMerge(
'col-span-full',
'row-start-1',
'sm:pe-9',
'pe-10',
className,
),
"col-span-full",
"row-start-1",
"sm:pe-8",
"pe-9",
className
)
)}
/>
<Button
@ -65,18 +64,20 @@ export function DatePickerInput({
isIconOnly
data-ui="trigger"
className={[
'focus-visible:-outline-offset-1',
'row-start-1',
'-col-end-1',
'place-self-center',
'text-muted group-hover:text-foreground',
].join(' ')}
"me-1",
"focus-visible:-outline-offset-1",
"row-start-1",
"-col-end-1",
"place-self-center",
"not-disabled:hover:bg-transparent",
"not-disabled:not-hover:text-muted",
].join(" ")}
>
<CalendarIcon />
</Button>
</Group>
<Popover placement="bottom" className="rounded-xl">
<Popover placement="bottom" className="rounded-lg">
<Dialog>
<Calendar yearRange={yearRange} />
</Dialog>
@ -101,12 +102,12 @@ export function DatePickerButton({
<Group data-ui="control">
<Button
className={twMerge(
'border-input w-full min-w-52 flex-1 justify-between px-3 leading-6 font-normal',
className,
"w-full min-w-52 flex-1 justify-between px-3 leading-6 font-normal",
className
)}
variant="outline"
>
{formattedDate === '' ? (
{formattedDate === "" ? (
<span className="text-muted">{children}</span>
) : (
<span>{formattedDate}</span>
@ -118,7 +119,7 @@ export function DatePickerButton({
<DateInput className="hidden" aria-hidden />
</Group>
<Popover placement="bottom" className="rounded-xl">
<Popover placement="bottom" className="rounded-lg">
<Dialog>
<Calendar />
</Dialog>

View file

@ -0,0 +1,28 @@
import { Icon } from "../../icon";
export function CalendarIcon({
"aria-label": arialLabel,
...props
}: React.JSX.IntrinsicElements["svg"]) {
return (
<Icon aria-label={arialLabel}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="M8 2v4" />
<path d="M16 2v4" />
<rect width="18" height="18" x="3" y="4" rx="2" />
<path d="M3 10h18" />
</svg>
</Icon>
);
}

View file

@ -18,6 +18,7 @@ export type Database = {
Row: {
availability_data: Json
created_at: string
exceptions: Json | null
id: number
updated_at: string
user_id: string
@ -25,6 +26,7 @@ export type Database = {
Insert: {
availability_data?: Json
created_at?: string
exceptions?: Json | null
id?: number
updated_at?: string
user_id: string
@ -32,6 +34,7 @@ export type Database = {
Update: {
availability_data?: Json
created_at?: string
exceptions?: Json | null
id?: number
updated_at?: string
user_id?: string