Add slot + public page + first sign up
This commit is contained in:
parent
5486d89726
commit
9488f6e5b6
9 changed files with 585 additions and 19 deletions
|
|
@ -157,6 +157,31 @@ describe("generateTimeSlots", () => {
|
|||
expect(slots[1].time).to.equal("10:00");
|
||||
expect(slots[7].time).to.equal("16:00");
|
||||
});
|
||||
|
||||
it("should generate slots for the next day correctly", () => {
|
||||
const currentTime = new Date("2025-09-30T19:39:30.000Z"); // Current time
|
||||
const nextDay = new Date("2025-10-01T19:39:30.000Z"); // Next day
|
||||
|
||||
const slots = generateTimeSlots(
|
||||
currentTime, // currentTime (2025-09-30)
|
||||
nextDay, // date (2025-10-01)
|
||||
basicAvailability,
|
||||
basicEventTypeConfig,
|
||||
[],
|
||||
existingEvents
|
||||
);
|
||||
|
||||
expect(slots).to.have.length(16); // 8 hours * 2 slots per hour
|
||||
expect(slots[0].time).to.equal("09:00");
|
||||
expect(slots[0].date).to.equal("2025-10-01");
|
||||
expect(slots[15].time).to.equal("16:30");
|
||||
expect(slots[15].date).to.equal("2025-10-01");
|
||||
|
||||
// All slots should be available since it's a future day
|
||||
slots.forEach((slot) => {
|
||||
expect(slot.available).to.be.true;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Exception handling", () => {
|
||||
|
|
|
|||
101
api/src/tablo.ts
101
api/src/tablo.ts
|
|
@ -98,6 +98,107 @@ tabloRouter.post("/create", async (c) => {
|
|||
return c.json({ message: "Tablo created successfully" });
|
||||
});
|
||||
|
||||
type PostTabloWithOwner = Omit<TabloInsert, "owner_id"> & {
|
||||
event: EventInsertInTablo;
|
||||
owner_short_id: string;
|
||||
};
|
||||
|
||||
tabloRouter.post("/create-and-invite", async (c) => {
|
||||
const user = c.get("user");
|
||||
const supabase = c.get("supabase");
|
||||
const streamServerClient = c.get("streamServerClient");
|
||||
const data = await c.req.json();
|
||||
|
||||
const typedPayload = data as PostTabloWithOwner;
|
||||
|
||||
// Validate that owner_id is provided
|
||||
if (!typedPayload.owner_short_id) {
|
||||
return c.json({ error: "owner_id is required" }, 400);
|
||||
}
|
||||
|
||||
if (!typedPayload.event) {
|
||||
return c.json({ error: "event is required" }, 400);
|
||||
}
|
||||
|
||||
// TODO: Verify that the owner_id is correct
|
||||
const { data: ownerData, error: ownerError } = await supabase
|
||||
.from("profiles")
|
||||
.select("id, name")
|
||||
.eq("short_user_id", typedPayload.owner_short_id)
|
||||
.single();
|
||||
|
||||
const { data: invitedUser, error: invitedUserError } = await supabase
|
||||
.from("profiles")
|
||||
.select("id, name")
|
||||
.eq("id", user.id)
|
||||
.single();
|
||||
|
||||
if (ownerError || !ownerData) {
|
||||
return c.json({ error: "owner_id is incorrect" }, 400);
|
||||
}
|
||||
|
||||
const ownerDataTyped = ownerData as { id: string; name: string };
|
||||
const ownerId = ownerDataTyped.id;
|
||||
const invitedUserDataTyped = invitedUser as { id: string; name: string };
|
||||
|
||||
// TODO: Verify that the event start and end correspond to a slot
|
||||
|
||||
// Create the tablo with the specified owner
|
||||
const { data: insertedTablo, error } = await supabase
|
||||
.from("tablos")
|
||||
.insert({
|
||||
name: `${ownerDataTyped.name || "Propriétaire"} / ${
|
||||
invitedUserDataTyped.name || "Invité"
|
||||
}`,
|
||||
color: "bg-blue-500",
|
||||
status: "todo",
|
||||
owner_id: ownerId,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
return c.json({ error: error.message }, 500);
|
||||
}
|
||||
|
||||
const tabloData = insertedTablo as Tables<"tablos">;
|
||||
|
||||
// Create Stream chat channel with the owner as creator
|
||||
const channel = streamServerClient.channel("messaging", tabloData.id, {
|
||||
// @ts-ignore
|
||||
name: tabloData.name,
|
||||
created_by_id: ownerId,
|
||||
members: [ownerId, user.id],
|
||||
});
|
||||
await channel.create();
|
||||
|
||||
// Grant access to the current user (invited user) as a non-admin member
|
||||
const { error: tabloAccessError } = await supabase
|
||||
.from("tablo_access")
|
||||
.insert({
|
||||
tablo_id: tabloData.id,
|
||||
user_id: user.id,
|
||||
// ** IMPORTANT **
|
||||
is_admin: false,
|
||||
// -------------
|
||||
is_active: true,
|
||||
granted_by: ownerId,
|
||||
});
|
||||
|
||||
if (tabloAccessError) {
|
||||
console.error("tabloAccessError", tabloAccessError);
|
||||
return c.json({ error: tabloAccessError.message }, 500);
|
||||
}
|
||||
|
||||
await supabase.from("events").insert({
|
||||
...typedPayload.event,
|
||||
tablo_id: tabloData.id,
|
||||
created_by: ownerId,
|
||||
});
|
||||
|
||||
return c.json({ message: "Tablo created and user invited successfully" });
|
||||
});
|
||||
|
||||
tabloRouter.patch("/update", async (c) => {
|
||||
const user = c.get("user");
|
||||
const supabase = c.get("supabase");
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import { Hono } from "hono";
|
||||
import { authMiddleware, streamChatMiddleware } from "./middleware.js";
|
||||
import {
|
||||
authMiddleware,
|
||||
emailMiddleware,
|
||||
streamChatMiddleware,
|
||||
} from "./middleware.js";
|
||||
import type { SupabaseClient, User } from "@supabase/supabase-js";
|
||||
import { StreamChat } from "stream-chat";
|
||||
import type { Transporter } from "nodemailer";
|
||||
|
|
@ -16,6 +20,7 @@ export const userRouter = new Hono<{
|
|||
|
||||
userRouter.use(authMiddleware);
|
||||
userRouter.use(streamChatMiddleware);
|
||||
userRouter.use(emailMiddleware);
|
||||
|
||||
userRouter.post("/sign-up-to-stream", async (c) => {
|
||||
const { id } = c.get("user");
|
||||
|
|
@ -70,3 +75,82 @@ userRouter.get("/me", async (c) => {
|
|||
streamToken: token,
|
||||
});
|
||||
});
|
||||
|
||||
userRouter.post("/mark-temporary", async (c) => {
|
||||
const user = c.get("user");
|
||||
const supabase = c.get("supabase");
|
||||
const transporter = c.get("transporter");
|
||||
|
||||
const body = await c.req.json();
|
||||
const { temporary_password } = body;
|
||||
|
||||
const { data: profile, error } = await supabase
|
||||
.from("profiles")
|
||||
.update({
|
||||
is_temporary: true,
|
||||
})
|
||||
.eq("id", user.id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
return c.json({ error: error.message }, 500);
|
||||
}
|
||||
|
||||
try {
|
||||
if (profile?.email) {
|
||||
const mailOptions = {
|
||||
from: process.env.EMAIL_USER,
|
||||
to: profile.email,
|
||||
subject: "Bienvenue sur XTablo - Votre mot de passe temporaire",
|
||||
text: `Bienvenue sur XTablo !
|
||||
|
||||
Votre compte a été créé avec succès. Voici vos informations de connexion :
|
||||
|
||||
Email : ${profile.email}
|
||||
Mot de passe temporaire : ${temporary_password}
|
||||
|
||||
Pour des raisons de sécurité, nous vous recommandons fortement de changer ce mot de passe temporaire lors de votre première connexion.
|
||||
|
||||
Connectez-vous sur : ${process.env.FRONTEND_URL || "https://app.xtablo.com"}
|
||||
|
||||
Cordialement,
|
||||
L'équipe XTablo`,
|
||||
html: `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h2 style="color: #333;">Bienvenue sur XTablo !</h2>
|
||||
|
||||
<p>Votre compte a été créé avec succès. Voici vos informations de connexion :</p>
|
||||
|
||||
<div style="background-color: #f5f5f5; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
||||
<p><strong>Email :</strong> ${profile.email}</p>
|
||||
<p><strong>Mot de passe temporaire :</strong> <code style="background-color: #e1e1e1; padding: 2px 4px; border-radius: 3px;">${temporary_password}</code></p>
|
||||
</div>
|
||||
|
||||
<p style="color: #d9534f;"><strong>Important :</strong> Pour des raisons de sécurité, nous vous recommandons fortement de changer ce mot de passe temporaire lors de votre première connexion.</p>
|
||||
|
||||
<p>
|
||||
<a href="${process.env.FRONTEND_URL || "https://app.tablo.fr"}"
|
||||
style="background-color: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; display: inline-block;">
|
||||
Se connecter à XTablo
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p style="color: #666; font-size: 14px; margin-top: 30px;">
|
||||
Cordialement,<br>
|
||||
L'équipe XTablo
|
||||
</p>
|
||||
</div>
|
||||
`,
|
||||
};
|
||||
await transporter.sendMail(mailOptions);
|
||||
console.log(`Sending welcome email to temporary user: ${profile.email}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to send welcome email:", error);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
message: "User marked as temporary",
|
||||
});
|
||||
});
|
||||
|
|
|
|||
8
sql/21_is_temporary.sql
Normal file
8
sql/21_is_temporary.sql
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
-- Add is_temporary column to profiles table
|
||||
ALTER TABLE profiles ADD COLUMN is_temporary BOOLEAN DEFAULT FALSE;
|
||||
|
||||
-- Update the column to be NOT NULL with default value
|
||||
ALTER TABLE profiles ALTER COLUMN is_temporary SET NOT NULL;
|
||||
|
||||
-- Add a comment to document the column purpose
|
||||
COMMENT ON COLUMN profiles.is_temporary IS 'Indicates if the user account was created with a temporary password and needs to be changed on first login';
|
||||
|
|
@ -110,6 +110,79 @@ export function useSignUp({ redirectUrl }: { redirectUrl: string | null }) {
|
|||
return { mutate, isPending, errors };
|
||||
}
|
||||
|
||||
export function useSignUpWithoutPassword() {
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const { signUpToStream } = useSignUpToStream();
|
||||
const { mutateAsync, isPending } = useMutation<
|
||||
AuthResponse,
|
||||
{ message: string; code: string },
|
||||
{ email: string; name: string }
|
||||
>({
|
||||
mutationFn: async (data: { email: string; name: string }) => {
|
||||
// Generate a temporary password for the user
|
||||
const tempPassword =
|
||||
Math.random().toString(36).slice(-8) +
|
||||
Math.random().toString(36).slice(-8);
|
||||
|
||||
const { data: response, error } = await supabase.auth.signUp({
|
||||
email: data.email.trim(),
|
||||
password: tempPassword,
|
||||
options: {
|
||||
data: {
|
||||
first_name: data.name.trim().split(" ")[0] || "",
|
||||
last_name: data.name.trim().split(" ").slice(1).join(" ") || "",
|
||||
business_name: "",
|
||||
},
|
||||
},
|
||||
});
|
||||
if (error) throw error;
|
||||
if (response.session?.access_token) {
|
||||
await signUpToStream(response.session.access_token);
|
||||
}
|
||||
|
||||
// Mark the user as temporary
|
||||
if (response.session?.access_token) {
|
||||
await api.post(
|
||||
"/api/v1/users/mark-temporary",
|
||||
{
|
||||
temporary_password: tempPassword,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${response.session.access_token}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
return response;
|
||||
},
|
||||
onError: (error) => {
|
||||
const errMap: Record<string, string> = {};
|
||||
|
||||
match(error.code)
|
||||
.with("user_already_exists", () => {
|
||||
errMap["email"] = "Cette adresse email est déjà utilisée";
|
||||
})
|
||||
.otherwise(() => {
|
||||
toast.add(
|
||||
{
|
||||
title: "Erreur",
|
||||
description: error.message,
|
||||
type: "error",
|
||||
position: "top-left",
|
||||
},
|
||||
{
|
||||
timeout: 5000,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
setErrors(errMap);
|
||||
},
|
||||
});
|
||||
return { mutateAsync, isPending, errors };
|
||||
}
|
||||
|
||||
export function useSignUpToStream() {
|
||||
const { mutate: signUpToStream } = useMutation({
|
||||
mutationFn: async (accessToken: string) => {
|
||||
|
|
|
|||
|
|
@ -103,6 +103,36 @@ export function useAvailabilities() {
|
|||
},
|
||||
});
|
||||
|
||||
const { mutate: deleteException } = useMutation<
|
||||
void,
|
||||
Error,
|
||||
{ exceptionIndex: number }
|
||||
>({
|
||||
mutationFn: async ({ exceptionIndex }: { exceptionIndex: number }) => {
|
||||
const currentExceptions =
|
||||
(availabilities?.exceptions as Exception[] | null) || [];
|
||||
const updatedExceptions = currentExceptions.filter(
|
||||
(_, index) => index !== exceptionIndex
|
||||
);
|
||||
|
||||
const { error } = await supabase.from("availabilities").upsert(
|
||||
{
|
||||
availability_data:
|
||||
availabilities?.availability_data || DEFAULT_AVAILABILITIES,
|
||||
exceptions: updatedExceptions,
|
||||
user_id: session?.user.id,
|
||||
},
|
||||
{
|
||||
onConflict: "user_id",
|
||||
}
|
||||
);
|
||||
if (error) throw error;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["availabilities"] });
|
||||
},
|
||||
});
|
||||
|
||||
const [draftAvailabilities, setDraftAvailabilities] =
|
||||
useState<WeeklyAvailability | null>(null);
|
||||
|
||||
|
|
@ -119,5 +149,7 @@ export function useAvailabilities() {
|
|||
updateAvailabilities,
|
||||
draftAvailabilities: draftAvailabilities || DEFAULT_AVAILABILITIES,
|
||||
setDraftAvailabilities,
|
||||
exceptions: (availabilities?.exceptions as Exception[] | null) || [],
|
||||
deleteException,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { toast } from "@ui/ui-library/toast/toast-queue";
|
|||
import { RemoveNullFromObject } from "@ui/types/removeNull";
|
||||
import { useUser } from "@ui/providers/UserStoreProvider";
|
||||
import { CreateTablo } from "@ui/types/tablos.types";
|
||||
import { EventInsertInTablo } from "@ui/types/events.types";
|
||||
|
||||
type Tablo = Database["public"]["Tables"]["tablos"];
|
||||
|
||||
|
|
@ -68,6 +69,7 @@ export const useTabloMembers = (tabloId: string) => {
|
|||
|
||||
return { data, isLoading, error };
|
||||
};
|
||||
|
||||
// Create new tablo
|
||||
export const useCreateTablo = () => {
|
||||
const { session } = useSession();
|
||||
|
|
@ -101,6 +103,59 @@ export const useCreateTablo = () => {
|
|||
});
|
||||
};
|
||||
|
||||
// Create tablo with owner
|
||||
export const useCreateTabloWithOwner = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
unknown,
|
||||
unknown,
|
||||
CreateTablo & {
|
||||
owner_short_id: string;
|
||||
event: EventInsertInTablo;
|
||||
access_token: string;
|
||||
}
|
||||
>({
|
||||
mutationFn: async (
|
||||
tabloAndToken: CreateTablo & {
|
||||
owner_short_id: string;
|
||||
event: EventInsertInTablo;
|
||||
access_token: string;
|
||||
}
|
||||
) => {
|
||||
const { data } = await api.post(
|
||||
"/api/v1/tablos/create-and-invite",
|
||||
{
|
||||
owner_short_id: tabloAndToken.owner_short_id,
|
||||
event: tabloAndToken.event,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${tabloAndToken.access_token}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tablos"] });
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error(error);
|
||||
toast.add(
|
||||
{
|
||||
title: "Échec de la création du tablo",
|
||||
description: "Veuillez réessayer",
|
||||
type: "error",
|
||||
},
|
||||
{
|
||||
timeout: 5000,
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Update tablo
|
||||
export const useUpdateTablo = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
|
|
|||
|
|
@ -18,13 +18,19 @@ import {
|
|||
MonitorIcon,
|
||||
} from "lucide-react";
|
||||
import { usePublicSlots, TimeSlot } from "@ui/hooks/public";
|
||||
import { useSignUpWithoutPassword } from "@ui/hooks/auth";
|
||||
import { useCreateTabloWithOwner } from "@ui/hooks/tablos";
|
||||
import { EventInsertInTablo } from "@ui/types/events.types";
|
||||
|
||||
export function PublicBookingPage() {
|
||||
const { user_info, event_type_standard_name } = useParams<{
|
||||
user_info: string;
|
||||
event_type_standard_name: string;
|
||||
}>();
|
||||
|
||||
const {
|
||||
mutateAsync: signUpWithoutPassword,
|
||||
isPending: isSigningUpWithoutPassword,
|
||||
} = useSignUpWithoutPassword();
|
||||
const shortUserId = user_info?.substring(user_info.lastIndexOf("-") + 1);
|
||||
|
||||
const { data: publicSlots, isLoading: isLoadingSlots } = usePublicSlots(
|
||||
|
|
@ -32,6 +38,8 @@ export function PublicBookingPage() {
|
|||
event_type_standard_name || ""
|
||||
);
|
||||
|
||||
const { mutateAsync: createTabloWithOwner } = useCreateTabloWithOwner();
|
||||
|
||||
const userProfile = publicSlots?.user;
|
||||
const eventType = publicSlots?.eventType;
|
||||
const slotsData = publicSlots?.slots || {};
|
||||
|
|
@ -236,14 +244,48 @@ export function PublicBookingPage() {
|
|||
return isValid;
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
const handleSubmit = async () => {
|
||||
if (validateForm()) {
|
||||
// TODO: Implement account creation logic
|
||||
console.log("Creating account with:", {
|
||||
const { session } = await signUpWithoutPassword({
|
||||
email: formData.email,
|
||||
name: formData.name,
|
||||
slot: selectedSlot,
|
||||
});
|
||||
|
||||
// Calculate end time based on start time and duration
|
||||
const startTime = selectedSlot?.slot.time || "";
|
||||
const duration = eventType?.duration || 60; // duration in minutes
|
||||
|
||||
const calculateEndTime = (
|
||||
startTime: string,
|
||||
durationMinutes: number
|
||||
): string => {
|
||||
if (!startTime) return "";
|
||||
|
||||
const [hours, minutes] = startTime.split(":").map(Number);
|
||||
const startDate = new Date();
|
||||
startDate.setHours(hours, minutes, 0, 0);
|
||||
|
||||
const endDate = new Date(startDate.getTime() + durationMinutes * 60000);
|
||||
|
||||
return endDate.toTimeString().slice(0, 5); // Format as HH:MM
|
||||
};
|
||||
|
||||
const endTime = calculateEndTime(startTime, duration);
|
||||
|
||||
await createTabloWithOwner({
|
||||
name: eventType?.name || "",
|
||||
status: "todo",
|
||||
owner_short_id: shortUserId || "",
|
||||
event: {
|
||||
description: eventType?.description || "",
|
||||
end_time: endTime || "",
|
||||
start_date: selectedSlot?.slot.date || "",
|
||||
start_time: selectedSlot?.slot.time || "",
|
||||
title: eventType?.name || "",
|
||||
} as EventInsertInTablo,
|
||||
access_token: session?.access_token || "",
|
||||
});
|
||||
|
||||
handleCloseModal();
|
||||
}
|
||||
};
|
||||
|
|
@ -555,7 +597,12 @@ export function PublicBookingPage() {
|
|||
<Button variant="outline" onPress={handleCloseModal}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button variant="solid" onPress={handleSubmit}>
|
||||
<Button
|
||||
variant="solid"
|
||||
onPress={handleSubmit}
|
||||
isPending={isSigningUpWithoutPassword}
|
||||
pendingLabel="Création du compte..."
|
||||
>
|
||||
Créer le compte et réserver
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -36,12 +36,17 @@ interface TimeRange {
|
|||
}
|
||||
|
||||
export function AvailabilitiesPage() {
|
||||
const { updateAvailabilities, draftAvailabilities, setDraftAvailabilities } =
|
||||
useAvailabilities();
|
||||
const {
|
||||
updateAvailabilities,
|
||||
draftAvailabilities,
|
||||
setDraftAvailabilities,
|
||||
exceptions,
|
||||
deleteException,
|
||||
} = useAvailabilities();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<"reglages" | "visualisation">(
|
||||
"reglages"
|
||||
);
|
||||
const [activeTab, setActiveTab] = useState<
|
||||
"availabilities" | "visualisation" | "exceptions"
|
||||
>("availabilities");
|
||||
const [copyModalOpen, setCopyModalOpen] = useState(false);
|
||||
const [sourceDayData, setSourceDayData] = useState<{
|
||||
day: number;
|
||||
|
|
@ -95,12 +100,14 @@ export function AvailabilitiesPage() {
|
|||
<div>
|
||||
<h2 className="text-2xl font-bold">Disponibilités</h2>
|
||||
<Strong className="text-gray-500 mt-2 text-xl">
|
||||
{activeTab === "reglages"
|
||||
{activeTab === "availabilities"
|
||||
? "Définissez vos horaires de disponibilité pour chaque jour de la semaine"
|
||||
: "Visualisez votre planning hebdomadaire"}
|
||||
: activeTab === "visualisation"
|
||||
? "Visualisez votre planning hebdomadaire"
|
||||
: "Gérez vos exceptions de disponibilité"}
|
||||
</Strong>
|
||||
</div>
|
||||
{activeTab === "reglages" && (
|
||||
{activeTab === "availabilities" && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="lg"
|
||||
|
|
@ -180,14 +187,24 @@ export function AvailabilitiesPage() {
|
|||
{/* Tab Navigation */}
|
||||
<div className="flex border-b border-gray-200 dark:border-gray-700 mb-6">
|
||||
<button
|
||||
onClick={() => setActiveTab("reglages")}
|
||||
onClick={() => setActiveTab("availabilities")}
|
||||
className={`px-6 py-3 font-medium text-sm border-b-2 transition-colors ${
|
||||
activeTab === "reglages"
|
||||
activeTab === "availabilities"
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
Réglages
|
||||
Disponibilités
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("exceptions")}
|
||||
className={`px-6 py-3 font-medium text-sm border-b-2 transition-colors ${
|
||||
activeTab === "exceptions"
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
Exceptions
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("visualisation")}
|
||||
|
|
@ -202,7 +219,7 @@ export function AvailabilitiesPage() {
|
|||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
{activeTab === "reglages" && (
|
||||
{activeTab === "availabilities" && (
|
||||
<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">
|
||||
|
|
@ -285,6 +302,130 @@ export function AvailabilitiesPage() {
|
|||
slotDurationMinutes={60}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === "exceptions" && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold">Mes exceptions</h3>
|
||||
<Text className="text-gray-500 mt-1">
|
||||
Gérez vos exceptions de disponibilité pour des dates
|
||||
spécifiques
|
||||
</Text>
|
||||
</div>
|
||||
<Button
|
||||
variant="solid"
|
||||
size="lg"
|
||||
onPress={() => setExceptionModalOpen(true)}
|
||||
className="[--btn-bg:var(--color-blue-800)]"
|
||||
>
|
||||
<PlusIcon /> Ajouter une exception
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{exceptions.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-8">
|
||||
<Text className="text-gray-500 dark:text-gray-400 text-lg mb-4">
|
||||
Aucune exception définie
|
||||
</Text>
|
||||
<Text className="text-gray-400 dark:text-gray-500 text-sm">
|
||||
Les exceptions vous permettent de modifier vos
|
||||
disponibilités pour des dates spécifiques.
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{exceptions.map((exception, index) => (
|
||||
<div
|
||||
key={`${exception.date}-${index}`}
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Strong className="text-lg">
|
||||
{new Date(exception.date).toLocaleDateString(
|
||||
"fr-FR",
|
||||
{
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}
|
||||
)}
|
||||
</Strong>
|
||||
<span
|
||||
className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||
exception.type === "day"
|
||||
? "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"
|
||||
: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
|
||||
}`}
|
||||
>
|
||||
{exception.type === "day"
|
||||
? "Indisponible"
|
||||
: "Horaires personnalisés"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{exception.type === "hours" && "hours" in exception && (
|
||||
<div className="space-y-1">
|
||||
<Text className="text-sm text-gray-600 dark:text-gray-400 font-medium">
|
||||
Créneaux disponibles :
|
||||
</Text>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{exception.hours.map((timeRange, timeIndex) => (
|
||||
<span
|
||||
key={timeIndex}
|
||||
className="px-2 py-1 bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 rounded text-sm"
|
||||
>
|
||||
{timeRange.start} - {timeRange.end}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onPress={() => {
|
||||
deleteException(
|
||||
{ exceptionIndex: index },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.add({
|
||||
title: "Succès",
|
||||
description:
|
||||
"Exception supprimée avec succès",
|
||||
type: "success",
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error(err);
|
||||
toast.add({
|
||||
title: "Erreur",
|
||||
description:
|
||||
"Erreur lors de la suppression de l'exception",
|
||||
type: "error",
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
}}
|
||||
className="text-red-600 hover:text-red-700 border-red-300 hover:border-red-400"
|
||||
>
|
||||
Supprimer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Copy Modal */}
|
||||
|
|
|
|||
Loading…
Reference in a new issue