Move dispos, split types d'appels, and also put the tablo invitations in the top bar

This commit is contained in:
Arthur Belleville 2026-03-07 19:12:19 +01:00
parent 354831c82f
commit cd327c8b79
No known key found for this signature in database
13 changed files with 514 additions and 202 deletions

View file

@ -262,8 +262,11 @@ const inviteToTablo = (
const tabloId = c.req.param("tabloId");
const { email: recipientmail } = await c.req.json();
const recipientEmail = String(recipientmail || "")
.trim()
.toLowerCase();
if (sender.email === recipientmail) {
if (sender.email?.toLowerCase() === recipientEmail) {
return c.json({ error: "You cannot invite yourself" }, 400);
}
@ -292,7 +295,7 @@ const inviteToTablo = (
const introEmail = introConfigData?.config?.intro_email;
const { error } = await supabase.from("tablo_invites").insert({
invited_email: recipientmail,
invited_email: recipientEmail,
tablo_id: tabloId,
invited_by: sender.id,
invite_token: token,
@ -311,7 +314,7 @@ const inviteToTablo = (
const { data: recipientUser, error: recipientError } = await supabase
.from("profiles")
.select("id")
.eq("email", recipientmail)
.eq("email", recipientEmail)
.maybeSingle();
if (recipientError) {
@ -324,7 +327,7 @@ const inviteToTablo = (
supabase,
streamServerClient,
transporter,
recipientmail,
recipientEmail,
sender.email
);
@ -378,7 +381,7 @@ const inviteToTablo = (
// Let the user know that they have been invited to the tablo
await transporter.sendMail({
from: `${sender.email} via XTablo <noreply@xtablo.com>`,
to: recipientmail,
to: recipientEmail,
subject: "Vous avez été invité à un tablo",
html: `
${introEmail ? `<p>${introEmail}</p>` : ""}
@ -403,7 +406,8 @@ ${introEmail ? `<p>${introEmail}</p>` : ""}
const cancelPendingInvite = (
middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>
) =>
factory.createHandlers(middlewareManager.regularUserCheck, checkTabloAdmin, async (c) => {
factory.createHandlers(middlewareManager.regularUserCheck, async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const streamServerClient = c.get("streamServerClient");
const tabloId = c.req.param("tabloId");
@ -432,6 +436,27 @@ const cancelPendingInvite = (
return c.json({ error: "Invite is no longer pending" }, 400);
}
const isInvitee = invite.invited_email?.toLowerCase() === user.email?.toLowerCase();
const { data: adminAccess, error: adminAccessError } = await supabase
.from("tablo_access")
.select("id")
.eq("tablo_id", tabloId)
.eq("user_id", user.id)
.eq("is_active", true)
.eq("is_admin", true)
.maybeSingle();
if (adminAccessError) {
return c.json({ error: adminAccessError.message }, 500);
}
const isAdmin = !!adminAccess;
if (!isInvitee && !isAdmin) {
return c.json({ error: "You are not authorized to cancel this invite" }, 403);
}
const { error: cancelError } = await supabase
.from("tablo_invites")
.update({ is_pending: false })
@ -471,6 +496,112 @@ const cancelPendingInvite = (
return c.json({ message: "Invite cancelled successfully" });
});
const getPendingInvitesForCurrentUser = (
middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>
) =>
factory.createHandlers(middlewareManager.regularUserCheck, async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const { data: pendingInvites, error: pendingInvitesError } = await supabase
.from("tablo_invites")
.select("id, tablo_id, created_at")
.eq("invited_email", user.email?.toLowerCase())
.eq("is_pending", true)
.order("created_at", { ascending: false });
if (pendingInvitesError) {
return c.json({ error: pendingInvitesError.message }, 500);
}
const tabloIds = Array.from(new Set((pendingInvites || []).map((invite) => invite.tablo_id)));
let tablosById = new Map<string, string>();
if (tabloIds.length > 0) {
const { data: tablos, error: tablosError } = await supabase
.from("tablos")
.select("id, name")
.in("id", tabloIds);
if (tablosError) {
return c.json({ error: tablosError.message }, 500);
}
tablosById = new Map((tablos || []).map((tablo) => [tablo.id, tablo.name]));
}
return c.json({
invites: (pendingInvites || []).map((invite) => ({
id: invite.id,
tablo_id: invite.tablo_id,
tablo_name: tablosById.get(invite.tablo_id) || "Tablo",
created_at: invite.created_at,
})),
});
});
const acceptInviteById = (middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>) =>
factory.createHandlers(middlewareManager.regularUserCheck, async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const streamServerClient = c.get("streamServerClient");
const inviteId = Number(c.req.param("inviteId"));
if (!Number.isInteger(inviteId) || inviteId <= 0) {
return c.json({ error: "Invalid invite id" }, 400);
}
const { data: inviteData, error: inviteError } = await supabase
.from("tablo_invites")
.select("id, tablo_id, invited_by, invited_email, is_pending")
.eq("id", inviteId)
.maybeSingle();
if (inviteError) {
return c.json({ error: inviteError.message }, 500);
}
if (!inviteData || !inviteData.is_pending) {
return c.json({ error: "Invite not found or no longer pending" }, 404);
}
if (inviteData.invited_email?.toLowerCase() !== user.email?.toLowerCase()) {
return c.json({ error: "You are not authorized to accept this invite" }, 403);
}
try {
await upsertStreamUserFromProfile(supabase, streamServerClient, user.id);
} catch (error) {
console.error("error upserting joining user to stream", error);
return c.json({ error: "Failed to provision chat user" }, 500);
}
const { error: tabloAccessError } = await supabase.from("tablo_access").insert({
tablo_id: inviteData.tablo_id,
user_id: user.id,
is_admin: false,
is_active: true,
granted_by: inviteData.invited_by,
});
if (tabloAccessError) {
if (tabloAccessError.code !== "23505") {
return c.json({ error: tabloAccessError.message }, 500);
}
}
await supabase.from("tablo_invites").update({ is_pending: false }).eq("id", inviteData.id);
try {
await ensureTabloChannelMember(supabase, streamServerClient, inviteData.tablo_id, user.id);
} catch (error) {
console.error("error adding member to channel", error);
return c.json({ error: "Failed to sync chat access for this tablo" }, 500);
}
return c.json({ tablo_id: inviteData.tablo_id });
});
const joinTablo = factory.createHandlers(async (c) => {
const { token } = await c.req.json();
@ -712,6 +843,8 @@ export const getTabloRouter = (config: AppConfig) => {
tabloRouter.post("/create", ...createTablo(middlewareManager));
tabloRouter.patch("/update", ...updateTablo(middlewareManager));
tabloRouter.delete("/delete", ...deleteTablo);
tabloRouter.get("/invites/pending", ...getPendingInvitesForCurrentUser(middlewareManager));
tabloRouter.post("/invites/:inviteId/accept", ...acceptInviteById(middlewareManager));
tabloRouter.post("/invite/:tabloId", ...inviteToTablo(config, middlewareManager));
tabloRouter.delete("/invite/:tabloId/:inviteId", ...cancelPendingInvite(middlewareManager));
tabloRouter.post("/join", ...joinTablo);

View file

@ -118,7 +118,7 @@ describe("EventTypeCard", () => {
const { container } = renderWithProviders(
<EventTypeCard eventType={inactiveEventType} handleEditEventType={handleEditEventType} />
);
const card = container.querySelector(".opacity-60");
const card = container.querySelector(".opacity-70");
expect(card).toBeInTheDocument();
});
});

View file

@ -61,7 +61,11 @@ export function EventTypeCard({
return (
<Card
key={eventType.id}
className={`${eventType.isActive ? "opacity-100" : "opacity-60"} transition-shadow hover:shadow-lg`}
className={`${
eventType.isActive
? "opacity-100 border-[#804EEC]/30 bg-white dark:bg-[#804EEC]/5"
: "opacity-70 border-[#804EEC]/15"
} transition-all hover:shadow-[0_10px_25px_rgba(128,78,236,0.18)]`}
>
<CardHeader className="min-h-[80px]">
<CardTitle className="text-lg">{eventType.name}</CardTitle>
@ -71,6 +75,7 @@ export function EventTypeCard({
variant="ghost"
size="icon"
onClick={() => setIsEmbedModalOpen(true)}
className="text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label={t("eventTypeCard.aria.settings")}
>
<SettingsIcon className="w-4 h-4" />
@ -81,6 +86,7 @@ export function EventTypeCard({
onClick={() =>
window.open(getPublicLink(eventType.standardName ?? null, "normal"), "_blank")
}
className="text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label={t("eventTypeCard.aria.preview")}
>
<ExternalLinkIcon className="w-4 h-4" />
@ -89,6 +95,7 @@ export function EventTypeCard({
variant="ghost"
size="icon"
onClick={() => handleEditEventType(eventType.id, eventType as EventTypeConfig)}
className="text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label={t("eventTypeCard.aria.edit")}
>
<EditIcon className="w-4 h-4" />
@ -146,7 +153,7 @@ export function EventTypeCard({
</div>
</CardContent>
<CardFooter className="justify-between border-t">
<CardFooter className="justify-between border-t border-[#804EEC]/20">
<span className="text-muted-foreground">{t("eventTypeCard.status")}</span>
<Button
variant={eventType.isActive ? "default" : "outline"}
@ -157,7 +164,11 @@ export function EventTypeCard({
isActive: !eventType.isActive,
})
}
className="text-sm"
className={`text-sm ${
eventType.isActive
? "bg-[#804EEC] text-white hover:bg-[#6f3fd4]"
: "border-[#804EEC]/40 text-[#804EEC] hover:bg-[#804EEC]/10 hover:text-[#6f3fd4]"
}`}
>
{eventType.isActive ? <CheckIcon /> : <XIcon />}
{eventType.isActive ? t("eventTypeCard.active") : t("eventTypeCard.inactive")}

View file

@ -253,6 +253,7 @@ export function EventTypeModal({
variant="default"
onClick={handleSaveEventType}
disabled={!formData.name?.trim() || !formData.duration}
className="bg-[#804EEC] hover:bg-[#6f3fd4] text-white"
>
{editingEventType
? t("eventTypeModal.buttons.edit")

View file

@ -13,7 +13,6 @@ import {
import { TypographyLarge, TypographyMuted } from "@xtablo/ui/components/typography";
import { cva, type VariantProps } from "class-variance-authority";
import {
CalendarCheckIcon,
CalendarIcon,
Circle,
Compass,
@ -203,14 +202,6 @@ export function UserMenuPopover({ isCollapsed }: { isCollapsed: boolean }) {
/>
</RouterLink>
<RouterLink to="/events">
<MenuDropdownItem
icon={<CalendarCheckIcon className="w-5 h-5" aria-hidden="true" />}
label={t("myEvents")}
variant="default"
/>
</RouterLink>
<RouterLink to="/availabilities">
<MenuDropdownItem
icon={<CalendarIcon className="w-5 h-5" aria-hidden="true" />}

View file

@ -34,12 +34,17 @@ import {
} from "lucide-react";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Link, useLocation, useSearchParams } from "react-router-dom";
import { Link, useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { useLogout } from "../hooks/auth";
import {
useNotifications,
useNotificationsSubscription,
} from "../hooks/notifications";
import {
useAcceptTabloInvite,
useCancelTabloInvite,
useReceivedTabloInvites,
} from "../hooks/tablo_invites";
import { useUser } from "../providers/UserStoreProvider";
type Notification = Database["public"]["Tables"]["notifications"]["Row"];
@ -259,6 +264,81 @@ function NotificationDropdown() {
);
}
function TabloInvitesDropdown() {
const { t } = useTranslation("navigation");
const navigate = useNavigate();
const { data: invites = [] } = useReceivedTabloInvites();
const { mutate: acceptInvite, isPending: isAccepting } = useAcceptTabloInvite();
const { mutate: dismissInvite, isPending: isDismissing } = useCancelTabloInvite();
const isActionPending = isAccepting || isDismissing;
if (invites.length === 0) {
return null;
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="relative h-10 border border-[#EAECF0] dark:border-gray-700 rounded-[8px] text-[#0C111D] dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 px-3"
aria-label={t("invites.title")}
>
{t("invites.title")}
<span className="absolute -top-1 -right-1 flex items-center justify-center min-w-4 h-4 px-1 rounded-full bg-[#804EEC] text-white text-[10px] font-semibold leading-none">
{invites.length}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="min-w-80 bg-white border border-[#EAECF0] p-1 rounded-lg text-gray-900 shadow-lg"
side="bottom"
align="end"
sideOffset={8}
>
<div className="px-4 py-3 border-b border-gray-100">
<TypographySmall className="font-semibold text-gray-900">{t("invites.title")}</TypographySmall>
</div>
<div className="max-h-[340px] overflow-y-auto divide-y divide-gray-100">
{invites.map((invite) => (
<div key={invite.id} className="px-4 py-3">
<TypographySmall className="font-medium text-gray-900">{invite.tablo_name}</TypographySmall>
<div className="mt-3 flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={isActionPending}
onClick={() => dismissInvite({ tabloId: invite.tablo_id, inviteId: invite.id })}
>
{t("invites.dismiss")}
</Button>
<Button
size="sm"
className="bg-[#804EEC] text-white hover:bg-[#6f3fd4]"
disabled={isActionPending}
onClick={() => {
acceptInvite(
{ inviteId: invite.id },
{
onSuccess: ({ tablo_id }) => {
navigate(`/tablos/${tablo_id}`);
},
}
);
}}
>
{t("invites.accept")}
</Button>
</div>
</div>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}
function ProfileDropdown() {
const { t } = useTranslation("navigation");
const user = useUser();
@ -314,13 +394,6 @@ function ProfileDropdown() {
</DropdownMenuItem>
</Link>
<Link to="/events">
<DropdownMenuItem className="cursor-pointer gap-2 text-gray-700 dark:text-gray-200 focus:bg-gray-100 dark:focus:bg-gray-800">
<CalendarCheckIcon className="w-4 h-4" />
{t("myEvents")}
</DropdownMenuItem>
</Link>
<Link to="/availabilities">
<DropdownMenuItem className="cursor-pointer gap-2 text-gray-700 dark:text-gray-200 focus:bg-gray-100 dark:focus:bg-gray-800">
<CalendarIcon className="w-4 h-4" />
@ -401,6 +474,7 @@ export function TopBar() {
</Link>
</div>
)}
<TabloInvitesDropdown />
<NotificationDropdown />
<ProfileDropdown />
</div>

View file

@ -6,6 +6,12 @@ import { useUser } from "../providers/UserStoreProvider";
import { useAuthedApi } from "./auth";
type TabloInvite = Database["public"]["Tables"]["tablo_invites"]["Row"];
type PendingReceivedInvite = {
id: number;
tablo_id: string;
tablo_name: string;
created_at: string;
};
// Fetch all pending invites created by the current user
// export const usePendingTabloInvites = () => {
@ -62,7 +68,9 @@ export const useCancelTabloInvite = () => {
},
onSuccess: (_data, { tabloId }) => {
queryClient.invalidateQueries({ queryKey: ["tablo-invites", tabloId] });
queryClient.invalidateQueries({ queryKey: ["tablo-invites", "received"] });
queryClient.invalidateQueries({ queryKey: ["tablo-members", tabloId] });
queryClient.invalidateQueries({ queryKey: ["notifications"] });
toast.add(
{
title: "Invitation retirée",
@ -85,3 +93,56 @@ export const useCancelTabloInvite = () => {
},
});
};
export const useReceivedTabloInvites = () => {
const api = useAuthedApi();
return useQuery({
queryKey: ["tablo-invites", "received"],
queryFn: async () => {
const { data } = await api.get<{ invites: PendingReceivedInvite[] }>(
"/api/v1/tablos/invites/pending"
);
return data.invites;
},
refetchInterval: 30000,
});
};
export const useAcceptTabloInvite = () => {
const api = useAuthedApi();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ inviteId }: { inviteId: number }) => {
const { data } = await api.post<{ tablo_id: string }>(
`/api/v1/tablos/invites/${inviteId}/accept`
);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tablo-invites", "received"] });
queryClient.invalidateQueries({ queryKey: ["tablos"] });
queryClient.invalidateQueries({ queryKey: ["notifications"] });
toast.add(
{
title: "Invitation acceptée",
description: "Vous avez rejoint le tablo",
type: "success",
},
{ timeout: 3000 }
);
},
onError: (error) => {
console.error("Error accepting invite:", error);
toast.add(
{
title: "Erreur",
description: "Impossible d'accepter l'invitation",
type: "error",
},
{ timeout: 5000 }
);
},
});
};

View file

@ -4,7 +4,8 @@
"tabs": {
"availabilities": "Availabilities",
"exceptions": "Exceptions",
"visualization": "Visualization"
"visualization": "Visualization",
"callTypes": "Call types"
},
"actions": {
"save": "Save",
@ -50,6 +51,14 @@
"exceptionDate": "Exception date"
}
},
"callTypes": {
"title": "Call Types",
"description": "Create and manage your call types for bookings.",
"create": "New Call Type",
"empty": "No call types configured yet.",
"readOnly": "You are in read-only mode. You cannot create call types.",
"required": "Please fill all required fields."
},
"copy": {
"title": "Copy {{day}} hours",
"description": "Select the days you want to copy these hours to",

View file

@ -26,5 +26,10 @@
"noNotifications": "No new notifications",
"allCaughtUp": "You're all caught up!",
"viewAll": "View all notifications"
},
"invites": {
"title": "Invites",
"dismiss": "Dismiss",
"accept": "Accept"
}
}

View file

@ -4,7 +4,8 @@
"tabs": {
"availabilities": "Disponibilités",
"exceptions": "Exceptions",
"visualization": "Visualisation"
"visualization": "Visualisation",
"callTypes": "Types d'appel"
},
"actions": {
"save": "Enregistrer",
@ -50,6 +51,14 @@
"exceptionDate": "Date de l'exception"
}
},
"callTypes": {
"title": "Types d'appel",
"description": "Créez et gérez vos types d'appel pour vos réservations.",
"create": "Nouveau Type d'appel",
"empty": "Aucun type d'appel configuré pour le moment.",
"readOnly": "Vous êtes en mode lecture seule. Vous ne pouvez pas créer de type d'appel.",
"required": "Veuillez remplir tous les champs obligatoires."
},
"copy": {
"title": "Copier les horaires de {{day}}",
"description": "Sélectionnez les jours vers lesquels vous souhaitez copier ces horaires",

View file

@ -26,5 +26,10 @@
"noNotifications": "Aucune nouvelle notification",
"allCaughtUp": "Vous êtes à jour !",
"viewAll": "Voir toutes les notifications"
},
"invites": {
"title": "Invitations",
"dismiss": "Ignorer",
"accept": "Accepter"
}
}

View file

@ -25,13 +25,17 @@ import { Strong, Text, TypographyH3, TypographyMuted } from "@xtablo/ui/componen
import { Plus as PlusIcon, SaveIcon } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { EventTypeCard } from "../components/EventTypeCard";
import { EventTypeModal } from "../components/EventTypeModal";
import { ExceptionModal } from "../components/ExceptionModal";
import { EventTypeConfig, useEventTypes } from "../hooks/event-types";
import {
DEFAULT_AVAILABILITIES,
Exception,
useAvailabilities,
WeeklyAvailability,
} from "../hooks/availabilities";
import { useIsReadOnlyUser } from "../providers/UserStoreProvider";
const DAYS_OF_WEEK = [0, 1, 2, 3, 4, 5, 6];
const DAYS_OF_WEEK_DISPLAY = [
@ -51,6 +55,7 @@ interface TimeRange {
export function AvailabilitiesPage() {
const { t } = useTranslation(["availabilities", "common"]);
const isReadOnly = useIsReadOnlyUser();
const {
updateAvailabilities,
draftAvailabilities,
@ -59,6 +64,7 @@ export function AvailabilitiesPage() {
deleteException,
isModified,
} = useAvailabilities();
const { eventTypes: eventTypesData, addEventType, updateEventType } = useEventTypes();
const [copyModalOpen, setCopyModalOpen] = useState(false);
const [sourceDayData, setSourceDayData] = useState<{
@ -68,6 +74,20 @@ export function AvailabilitiesPage() {
} | null>(null);
const [selectedDays, setSelectedDays] = useState<number[]>([]);
const [exceptionModalOpen, setExceptionModalOpen] = useState(false);
const [isEventTypeModalOpen, setIsEventTypeModalOpen] = useState(false);
const [editingEventType, setEditingEventType] = useState<
(EventTypeConfig & { id: string }) | null
>(null);
const [eventTypeFormData, setEventTypeFormData] = useState<
Partial<EventTypeConfig> & { isActive?: boolean }
>({
name: "",
description: "",
duration: 60,
bufferTime: 15,
maxBookingsPerDay: 8,
requiresApproval: false,
});
const handleCopyToOtherDays = (sourceDay: number, enabled: boolean, timeRanges: TimeRange[]) => {
setSourceDayData({ day: sourceDay, enabled, timeRanges });
@ -98,6 +118,58 @@ export function AvailabilitiesPage() {
});
};
const handleCreateEventType = () => {
if (isReadOnly) {
toast.add({
title: t("common:error"),
description: t("availabilities:callTypes.readOnly"),
type: "error",
});
return;
}
setEditingEventType(null);
setEventTypeFormData({
name: "",
description: "",
duration: 60,
isActive: true,
bufferTime: 15,
maxBookingsPerDay: 8,
requiresApproval: false,
});
setIsEventTypeModalOpen(true);
};
const handleEditEventType = (id: string, eventType: EventTypeConfig) => {
setEditingEventType({ id, ...eventType });
setEventTypeFormData(eventType);
setIsEventTypeModalOpen(true);
};
const handleSaveEventType = () => {
if (!eventTypeFormData.name) {
toast.add({
title: t("common:error"),
description: t("availabilities:callTypes.required"),
type: "error",
});
return;
}
if (editingEventType) {
updateEventType({
id: editingEventType.id,
eventType: eventTypeFormData as EventTypeConfig,
});
} else {
addEventType({ eventType: eventTypeFormData as EventTypeConfig });
}
setIsEventTypeModalOpen(false);
setEditingEventType(null);
};
// Filter exceptions to only show upcoming dates (today and future)
const today = new Date();
today.setHours(0, 0, 0, 0);
@ -111,7 +183,7 @@ export function AvailabilitiesPage() {
return (
<div className="min-h-screen">
<header className="bg-card shadow-sm border-b border-border">
<header className="bg-card shadow-sm border-b border-[#804EEC]/20">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div>
<TypographyH3>{t("availabilities:title")}</TypographyH3>
@ -122,14 +194,31 @@ export function AvailabilitiesPage() {
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<Tabs defaultValue="availabilities" className="w-full">
<TabsList className="mb-6">
<TabsTrigger value="availabilities">
<TabsList className="mb-6 border border-[#804EEC]/25 bg-[#804EEC]/5 p-1">
<TabsTrigger
value="availabilities"
className="data-[state=active]:bg-[#804EEC] data-[state=active]:text-white"
>
{t("availabilities:tabs.availabilities")}
</TabsTrigger>
<TabsTrigger value="exceptions">{t("availabilities:tabs.exceptions")}</TabsTrigger>
<TabsTrigger value="visualisation">
<TabsTrigger
value="exceptions"
className="data-[state=active]:bg-[#804EEC] data-[state=active]:text-white"
>
{t("availabilities:tabs.exceptions")}
</TabsTrigger>
<TabsTrigger
value="visualisation"
className="data-[state=active]:bg-[#804EEC] data-[state=active]:text-white"
>
{t("availabilities:tabs.visualization")}
</TabsTrigger>
<TabsTrigger
value="call-types"
className="data-[state=active]:bg-[#804EEC] data-[state=active]:text-white"
>
{t("availabilities:tabs.callTypes")}
</TabsTrigger>
</TabsList>
<TabsContent value="availabilities" className="space-y-4">
@ -138,7 +227,7 @@ export function AvailabilitiesPage() {
<Button
size="sm"
variant="default"
className="[--btn-bg:var(--color-green-800)]"
className="bg-[#804EEC] text-white hover:bg-[#6f3fd4]"
onClick={() => {
updateAvailabilities({
updatedAvailabilities: draftAvailabilities,
@ -149,20 +238,20 @@ export function AvailabilitiesPage() {
<SaveIcon /> {t("availabilities:actions.save")}
</Button>
)}
<Button
size="sm"
onClick={() => {
updateAvailabilities({
updatedAvailabilities: DEFAULT_AVAILABILITIES,
});
}}
className="py-1"
>
{t("availabilities:actions.businessHours")}
</Button>
<Button
size="sm"
variant="outline"
<Button
size="sm"
onClick={() => {
updateAvailabilities({
updatedAvailabilities: DEFAULT_AVAILABILITIES,
});
}}
className="py-1 border border-[#804EEC]/35 text-[#804EEC] bg-white hover:bg-[#804EEC]/10"
>
{t("availabilities:actions.businessHours")}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
const newAvailabilities: WeeklyAvailability = {};
DAYS_OF_WEEK.forEach((day) => {
@ -175,14 +264,14 @@ export function AvailabilitiesPage() {
updatedAvailabilities: newAvailabilities,
});
}}
className="py-1"
className="py-1 border-[#804EEC]/35 text-[#804EEC] hover:bg-[#804EEC]/10 hover:text-[#6f3fd4]"
>
{t("availabilities:actions.disableAll")}
</Button>
</div>
<div className="flex items-start">
<div className="flex-1 pr-6 border-r border-gray-200 dark:border-gray-700">
<div className="flex-1 pr-6 border-r border-[#804EEC]/20">
<div className="grid grid-cols-2 gap-4">
{DAYS_OF_WEEK.map((day) => (
<AvailabilityCard
@ -225,7 +314,7 @@ export function AvailabilitiesPage() {
</Text>
</div>
<Card className="bg-muted/30">
<Card className="bg-muted/30 border-[#804EEC]/20">
<CardContent className="px-4">
<Strong className="block mb-2">{t("availabilities:timezone.your")}</Strong>
<Text className="text-gray-500">
@ -241,7 +330,7 @@ export function AvailabilitiesPage() {
</CardContent>
</Card>
<Card className="bg-muted/30">
<Card className="bg-muted/30 border-[#804EEC]/20">
<CardContent className="px-4">
<Strong className="block mb-2">Information</Strong>
<Text className="text-gray-500 text-sm">
@ -270,7 +359,12 @@ export function AvailabilitiesPage() {
</Text>
</div>
{upcomingExceptions.length > 0 && (
<Button variant="default" size="lg" onClick={() => setExceptionModalOpen(true)}>
<Button
variant="default"
size="lg"
onClick={() => setExceptionModalOpen(true)}
className="bg-[#804EEC] text-white hover:bg-[#6f3fd4]"
>
<PlusIcon /> {t("availabilities:actions.addException")}
</Button>
)}
@ -285,7 +379,12 @@ export function AvailabilitiesPage() {
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button variant="default" size="lg" onClick={() => setExceptionModalOpen(true)}>
<Button
variant="default"
size="lg"
onClick={() => setExceptionModalOpen(true)}
className="bg-[#804EEC] text-white hover:bg-[#6f3fd4]"
>
<PlusIcon /> {t("availabilities:actions.addException")}
</Button>
</EmptyContent>
@ -295,7 +394,7 @@ export function AvailabilitiesPage() {
{upcomingExceptions.map(({ exception, originalIndex }) => (
<div
key={`${exception.date}-${originalIndex}`}
className="bg-white dark:bg-gray-700/40 rounded-lg shadow-sm dark:shadow-gray-900/20 p-4 border border-gray-200 dark:border-gray-600/50"
className="bg-white dark:bg-gray-700/40 rounded-lg shadow-sm dark:shadow-gray-900/20 p-4 border border-[#804EEC]/20"
>
<div className="flex justify-between items-start">
<div className="flex-1">
@ -375,6 +474,47 @@ export function AvailabilitiesPage() {
</div>
)}
</TabsContent>
<TabsContent value="call-types" className="space-y-6">
<section className="rounded-2xl border border-[#804EEC]/25 bg-card p-6">
<div className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 className="text-xl font-semibold">
{t("availabilities:callTypes.title")}
</h3>
<Text className="text-muted-foreground">
{t("availabilities:callTypes.description")}
</Text>
</div>
<Button
onClick={handleCreateEventType}
disabled={isReadOnly}
className="bg-[#804EEC] hover:bg-[#6f3fd4] text-white"
>
<PlusIcon className="w-4 h-4 mr-2" />
{t("availabilities:callTypes.create")}
</Button>
</div>
{eventTypesData.length === 0 ? (
<div className="rounded-xl border border-dashed border-[#804EEC]/35 bg-white/70 dark:bg-[#804EEC]/10 p-8 text-center">
<Text className="text-[#5c3aa9] dark:text-[#CDB8FF]">
{t("availabilities:callTypes.empty")}
</Text>
</div>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{eventTypesData.map((eventType) => (
<EventTypeCard
key={eventType.id}
eventType={eventType}
handleEditEventType={handleEditEventType}
/>
))}
</div>
)}
</section>
</TabsContent>
</Tabs>
</main>
@ -417,6 +557,7 @@ export function AvailabilitiesPage() {
variant="default"
disabled={selectedDays.length === 0}
onClick={applyCopyToSelectedDays}
className="bg-[#804EEC] text-white hover:bg-[#6f3fd4]"
>
{t("availabilities:copy.copyTo", { count: selectedDays.length })}
</Button>
@ -424,6 +565,15 @@ export function AvailabilitiesPage() {
</DialogContent>
</Dialog>
<EventTypeModal
isModalOpen={isEventTypeModalOpen}
setIsModalOpen={setIsEventTypeModalOpen}
editingEventType={editingEventType}
formData={eventTypeFormData as EventTypeConfig}
setFormData={setEventTypeFormData}
handleSaveEventType={handleSaveEventType}
/>
<ExceptionModal
isOpen={exceptionModalOpen}
onClose={() => setExceptionModalOpen(false)}

View file

@ -1,5 +1,4 @@
import { EventDetailsModal } from "@ui/components/EventDetailsModal";
import { EventTypeModal } from "@ui/components/EventTypeModal";
import { LoadingSpinner } from "@ui/components/LoadingSpinner";
import { getTextColorFromTabloColor, toast } from "@xtablo/shared";
import { EventAndTablo } from "@xtablo/shared/types/events.types";
@ -13,21 +12,17 @@ import {
SelectTrigger,
SelectValue,
} from "@xtablo/ui/components/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@xtablo/ui/components/tabs";
import { Strong, Text, TypographyH3, TypographyMuted } from "@xtablo/ui/components/typography";
import {
Calendar as CalendarIcon,
ChevronLeft,
ChevronRight,
PlusIcon,
SearchIcon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Outlet, useNavigate } from "react-router-dom";
import { twMerge } from "tailwind-merge";
import { EventTypeCard } from "../components/EventTypeCard";
import { EventTypeConfig, useEventTypes } from "../hooks/event-types";
import { useEventsByTablo } from "../hooks/events";
import { useGetAllTabloAccess, useTablosList } from "../hooks/tablos";
import { useIsReadOnlyUser } from "../providers/UserStoreProvider";
@ -42,7 +37,6 @@ export function EventsPage() {
const navigate = useNavigate();
const { t } = useTranslation(["pages", "common"]);
const isReadOnly = useIsReadOnlyUser();
const [activeTab, setActiveTab] = useState<"events" | "event-types">("events");
const statusOptions: BookingStatusOption[] = [
{ id: "upcoming", name: t("pages:events.filters.upcoming") },
@ -58,29 +52,12 @@ export function EventsPage() {
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10);
// Event Types state
const [isEventTypeModalOpen, setIsEventTypeModalOpen] = useState(false);
const [editingEventType, setEditingEventType] = useState<
(EventTypeConfig & { id: string }) | null
>(null);
const [eventTypeFormData, setEventTypeFormData] = useState<
Partial<EventTypeConfig> & { isActive?: boolean }
>({
name: "",
description: "",
duration: 60,
bufferTime: 15,
maxBookingsPerDay: 8,
requiresApproval: false,
});
// Fetch data
const { data: tablos, isLoading: tablosLoading } = useTablosList();
const { data: events = [], isLoading: eventsLoading } = useEventsByTablo(
selectedTabloId !== "all" ? selectedTabloId : null
);
const { data: tabloAccess } = useGetAllTabloAccess();
const { eventTypes: eventTypesData, addEventType, updateEventType } = useEventTypes();
// Filter and search events
const filteredEvents = useMemo(() => {
@ -138,62 +115,6 @@ export function EventsPage() {
setCurrentPage(1);
}, [searchTerm, statusFilter, selectedTabloId, itemsPerPage]);
// Event Types handlers
const handleCreateEventType = () => {
if (isReadOnly) {
toast.add(
{
title: t("common:error"),
description:
"Vous êtes en mode lecture seule. Vous ne pouvez pas créer de type d'événement.",
type: "error",
},
{ timeout: 5000 }
);
return;
}
setEditingEventType(null);
setEventTypeFormData({
name: "",
description: "",
duration: 60,
isActive: true,
bufferTime: 15,
maxBookingsPerDay: 8,
requiresApproval: false,
});
setIsEventTypeModalOpen(true);
};
const handleEditEventType = (id: string, eventType: EventTypeConfig) => {
setEditingEventType({ id, ...eventType });
setEventTypeFormData(eventType as EventTypeConfig);
setIsEventTypeModalOpen(true);
};
const handleSaveEventType = () => {
if (!eventTypeFormData.name) {
toast.add({
title: "Erreur",
description: "Veuillez remplir tous les champs obligatoires",
type: "error",
});
return;
}
if (editingEventType) {
updateEventType({
id: editingEventType.id,
eventType: eventTypeFormData as EventTypeConfig,
});
} else {
addEventType({ eventType: eventTypeFormData as EventTypeConfig });
}
setIsEventTypeModalOpen(false);
setEditingEventType(null);
};
// Events handlers
const formatEventDateTime = (event: EventAndTablo) => {
if (!event.start_date) return "Date non définie";
@ -299,34 +220,16 @@ export function EventsPage() {
</div>
</header>
{/* Main Content with Tabs */}
{/* Main Content */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(value as "events" | "event-types")}
className="w-full"
>
<div className="flex items-center justify-between mb-6">
<TabsList>
<TabsTrigger value="events">{t("pages:events.tabs.events")}</TabsTrigger>
<TabsTrigger value="event-types">{t("pages:events.tabs.eventTypes")}</TabsTrigger>
</TabsList>
<div className="flex items-center justify-end mb-6">
<Button onClick={handleCreateEvent} disabled={isReadOnly}>
<CalendarIcon className="w-4 h-4 mr-2" />
{t("pages:events.createEvent")}
</Button>
</div>
{activeTab === "events" ? (
<Button onClick={handleCreateEvent} disabled={isReadOnly}>
<CalendarIcon className="w-4 h-4 mr-2" />
{t("pages:events.createEvent")}
</Button>
) : (
<Button onClick={handleCreateEventType} disabled={isReadOnly}>
<PlusIcon className="w-4 h-4 mr-2" />
{t("pages:events.createEventType")}
</Button>
)}
</div>
{/* Events Tab */}
<TabsContent value="events" className="space-y-6">
<div className="space-y-6">
{/* Filters */}
<div className="bg-card rounded-lg shadow-sm border border-border p-6">
<div className="flex flex-col lg:flex-row gap-4 items-start lg:items-center">
@ -606,38 +509,7 @@ export function EventsPage() {
</div>
</div>
)}
</TabsContent>
{/* Event Types Tab */}
<TabsContent value="event-types" className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{eventTypesData?.map((eventType) => (
<EventTypeCard
key={eventType.id}
eventType={eventType}
handleEditEventType={handleEditEventType}
/>
))}
</div>
{eventTypesData?.length === 0 && (
<div className="text-center py-12 bg-card rounded-lg shadow-sm border border-border">
<Text className="text-muted-foreground mb-4">
{t("pages:events.eventTypes.emptyState.title")}
</Text>
<Button
variant="default"
size="lg"
onClick={handleCreateEventType}
disabled={isReadOnly}
>
<PlusIcon className="w-4 h-4 mr-2" />{" "}
{t("pages:events.eventTypes.emptyState.button")}
</Button>
</div>
)}
</TabsContent>
</Tabs>
</div>
{/* Event Details Modal */}
<EventDetailsModal
@ -651,15 +523,6 @@ export function EventsPage() {
canEdit={selectedEvent ? canEditEvent(selectedEvent) : false}
/>
{/* Event Type Modal */}
<EventTypeModal
isModalOpen={isEventTypeModalOpen}
setIsModalOpen={setIsEventTypeModalOpen}
editingEventType={editingEventType}
formData={eventTypeFormData as EventTypeConfig}
setFormData={setEventTypeFormData}
handleSaveEventType={handleSaveEventType}
/>
</main>
{/* Render child routes (e.g. EventModal) */}