Move dispos, split types d'appels, and also put the tablo invitations in the top bar
This commit is contained in:
parent
354831c82f
commit
cd327c8b79
13 changed files with 514 additions and 202 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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" />}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -26,5 +26,10 @@
|
|||
"noNotifications": "Aucune nouvelle notification",
|
||||
"allCaughtUp": "Vous êtes à jour !",
|
||||
"viewAll": "Voir toutes les notifications"
|
||||
},
|
||||
"invites": {
|
||||
"title": "Invitations",
|
||||
"dismiss": "Ignorer",
|
||||
"accept": "Accepter"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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) */}
|
||||
|
|
|
|||
Loading…
Reference in a new issue