Fix react rendering issue
This commit is contained in:
parent
515a875e88
commit
fd5137e91e
3 changed files with 433 additions and 229 deletions
|
|
@ -88,3 +88,54 @@ See `.env.example` for required environment variables.
|
|||
## Deployment
|
||||
|
||||
The API is deployed to Google Cloud Run. See `cloudbuild.yaml` for deployment configuration.
|
||||
|
||||
## Dokploy Deployment
|
||||
|
||||
Prefer a Dokploy `Application` instead of a Compose service.
|
||||
|
||||
Dokploy supports Dockerfile-based applications directly, which fits this API better than maintaining a dedicated compose wrapper. Configure the application like this:
|
||||
|
||||
- Build Type: `Dockerfile`
|
||||
- Dockerfile Path: `apps/api/Dockerfile`
|
||||
- Docker Context Path: `.`
|
||||
- Docker Build Stage: `final`
|
||||
- Port: `8080`
|
||||
- Run Command: leave empty and use the image `CMD`
|
||||
|
||||
Set these Dokploy environment variables:
|
||||
|
||||
- `NODE_ENV`
|
||||
- `DD_SERVICE=xtablo-api`
|
||||
- `DD_ENV`
|
||||
- `DD_VERSION`
|
||||
- `DD_LOGS_INJECTION=true`
|
||||
- `SUPABASE_URL`
|
||||
- `SUPABASE_SERVICE_ROLE_KEY`
|
||||
- `SUPABASE_CONNECTION_STRING`
|
||||
- `SUPABASE_CA_CERT`
|
||||
- `STRIPE_SECRET_KEY`
|
||||
- `STRIPE_WEBHOOK_SECRET`
|
||||
- `STRIPE_SOLO_PRICE_ID`
|
||||
- `STRIPE_TEAM_PRICE_ID`
|
||||
- `STRIPE_FOUNDER_PRICE_ID`
|
||||
- `EMAIL_USER`
|
||||
- `EMAIL_CLIENT_ID`
|
||||
- `EMAIL_CLIENT_SECRET`
|
||||
- `EMAIL_REFRESH_TOKEN`
|
||||
- `R2_ACCOUNT_ID`
|
||||
- `R2_ACCESS_KEY_ID`
|
||||
- `R2_SECRET_ACCESS_KEY`
|
||||
- `TASKS_SECRET`
|
||||
|
||||
For Datadog logs, the Dokploy host should already run a Datadog Agent with Docker log collection enabled. Then add these Docker Swarm labels in Dokploy `Advanced Settings -> Swarm Settings -> Labels`:
|
||||
|
||||
- `com.datadoghq.tags.service=xtablo-api`
|
||||
- `com.datadoghq.tags.env=${DD_ENV}`
|
||||
- `com.datadoghq.tags.version=${DD_VERSION}`
|
||||
- `com.datadoghq.ad.logs=[{"source":"nodejs","service":"xtablo-api"}]`
|
||||
|
||||
This gives you:
|
||||
|
||||
- container logs in Datadog with `source: nodejs`
|
||||
- service tagging as `xtablo-api`
|
||||
- APM/log correlation via `dd-trace` plus `DD_LOGS_INJECTION=true`
|
||||
|
|
|
|||
145
apps/main/src/pages/planning.test.tsx
Normal file
145
apps/main/src/pages/planning.test.tsx
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import { SessionTestProvider } from "@xtablo/shared/contexts/SessionContext";
|
||||
import { ThemeProvider } from "@xtablo/shared/contexts/ThemeContext";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import { createMemoryRouter, RouterProvider } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import testI18n from "../i18n.test";
|
||||
import { TestUserStoreProvider, type User } from "../providers/UserStoreProvider";
|
||||
import { PlanningPage } from "./planning";
|
||||
|
||||
vi.mock("../hooks/events", () => ({
|
||||
useDeleteEvent: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useEventsByTablo: () => ({
|
||||
data: [
|
||||
{
|
||||
event_id: "event-1",
|
||||
tablo_id: "tablo-1",
|
||||
tablo_name: "Tablo Alpha",
|
||||
tablo_color: "bg-blue-500",
|
||||
start_date: "2026-04-22",
|
||||
start_time: "09:00:00",
|
||||
end_time: "10:00:00",
|
||||
title: "Kickoff",
|
||||
description: "Planning event",
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/tablos", () => ({
|
||||
useGetAllTabloAccess: () => ({
|
||||
data: [{ tablo_id: "tablo-1", is_admin: true }],
|
||||
}),
|
||||
useTablosList: () => ({
|
||||
data: [{ id: "tablo-1", name: "Tablo Alpha" }],
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../providers/UserStoreProvider", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../providers/UserStoreProvider")>();
|
||||
return {
|
||||
...actual,
|
||||
useIsReadOnlyUser: () => false,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../components/EventModal", () => ({
|
||||
EventModal: () => <div data-testid="event-modal" />,
|
||||
}));
|
||||
|
||||
const testUser: User = {
|
||||
id: "user-1",
|
||||
short_user_id: "u1",
|
||||
name: "John Doe",
|
||||
first_name: "John",
|
||||
last_name: "Doe",
|
||||
email: "john@example.com",
|
||||
avatar_url: null,
|
||||
is_temporary: false,
|
||||
is_client: false,
|
||||
client_onboarded_at: null,
|
||||
last_signed_in: null,
|
||||
plan: "none",
|
||||
created_at: new Date("2026-01-01").toISOString(),
|
||||
};
|
||||
|
||||
const createTestRouter = (initialEntry: string) =>
|
||||
createMemoryRouter(
|
||||
[
|
||||
{
|
||||
path: "/planning",
|
||||
element: <PlanningPage />,
|
||||
},
|
||||
{
|
||||
path: "/tasks",
|
||||
element: <div>Tasks page</div>,
|
||||
},
|
||||
],
|
||||
{ initialEntries: [initialEntry] }
|
||||
);
|
||||
|
||||
const renderPlanningRouter = (initialEntry: string) => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
staleTime: Infinity,
|
||||
},
|
||||
},
|
||||
});
|
||||
const router = createTestRouter(initialEntry);
|
||||
|
||||
return {
|
||||
router,
|
||||
...render(
|
||||
<I18nextProvider i18n={testI18n}>
|
||||
<ThemeProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SessionTestProvider
|
||||
testUser={{
|
||||
id: testUser.id,
|
||||
app_metadata: {},
|
||||
aud: "test",
|
||||
created_at: "2021-01-01",
|
||||
user_metadata: {
|
||||
first_name: testUser.first_name,
|
||||
last_name: testUser.last_name,
|
||||
avatar_url: testUser.avatar_url,
|
||||
full_name: testUser.name,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<TestUserStoreProvider user={testUser}>
|
||||
<RouterProvider router={router} />
|
||||
</TestUserStoreProvider>
|
||||
</SessionTestProvider>
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
</I18nextProvider>
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
describe("PlanningPage", () => {
|
||||
beforeEach(() => {
|
||||
testI18n.changeLanguage("fr");
|
||||
});
|
||||
|
||||
it("does not throw when leaving the planning page from the events tab", async () => {
|
||||
const { router } = renderPlanningRouter("/planning?tab=events");
|
||||
|
||||
expect(screen.getByText("Événements")).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
await expect(router.navigate("/tasks")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Tasks page")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1055,254 +1055,262 @@ export const PlanningPage = () => {
|
|||
);
|
||||
};
|
||||
|
||||
if (currentTab === "events") {
|
||||
return (
|
||||
<div className="min-h-screen bg-background px-4">
|
||||
{renderEventsView()}
|
||||
<EventModal
|
||||
mode="create"
|
||||
isOpen={isCreateEventOpen}
|
||||
onClose={() => setIsCreateEventOpen(false)}
|
||||
defaultTabloId={selectedTabloId !== "all" ? selectedTabloId : undefined}
|
||||
defaultDate={currentDate}
|
||||
/>
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="flex">
|
||||
{/* Sidebar */}
|
||||
<div className="w-64 bg-card border-r border-border min-h-screen">
|
||||
<div className="p-4">
|
||||
{/* Tablo Selector */}
|
||||
<div className="mb-4">
|
||||
<Select
|
||||
value={selectedTabloId}
|
||||
onValueChange={(value) => setSelectedTabloId(value)}
|
||||
disabled={tablosLoading}
|
||||
{currentTab === "events" ? (
|
||||
<div className="px-4">{renderEventsView()}</div>
|
||||
) : (
|
||||
<div className="flex">
|
||||
{/* Sidebar */}
|
||||
<div className="w-64 bg-card border-r border-border min-h-screen">
|
||||
<div className="p-4">
|
||||
{/* Tablo Selector */}
|
||||
<div className="mb-4">
|
||||
<Select
|
||||
value={selectedTabloId}
|
||||
onValueChange={(value) => setSelectedTabloId(value)}
|
||||
disabled={tablosLoading}
|
||||
>
|
||||
<SelectTrigger className="w-full" aria-label={t("planning:selectTablo")}>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
tablosLoading ? t("common:actions.loading") : t("planning:selectTablo")
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t("planning:allTablos")}</SelectItem>
|
||||
{tablos?.map((tablo) => (
|
||||
<SelectItem key={tablo.id} value={tablo.id}>
|
||||
{tablo.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (isReadOnly) {
|
||||
toast.add(
|
||||
{
|
||||
title: t("common:error"),
|
||||
description:
|
||||
"Vous êtes en mode lecture seule. Vous ne pouvez pas créer d'événement.",
|
||||
type: "error",
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (selectedTabloId === "all") {
|
||||
navigate(`/planning/create?date=${currentDate.toISOString()}`);
|
||||
} else {
|
||||
navigate(
|
||||
`/planning/create?tablo_id=${selectedTabloId}&date=${currentDate.toISOString()}`
|
||||
);
|
||||
}
|
||||
}}
|
||||
className="w-full bg-[#804EEC] hover:bg-[#6f3fd4] text-white"
|
||||
disabled={isReadOnly}
|
||||
>
|
||||
<SelectTrigger className="w-full" aria-label={t("planning:selectTablo")}>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
tablosLoading ? t("common:actions.loading") : t("planning:selectTablo")
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t("planning:allTablos")}</SelectItem>
|
||||
{tablos?.map((tablo) => (
|
||||
<SelectItem key={tablo.id} value={tablo.id}>
|
||||
{tablo.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<PlusIcon className="w-5 h-5 mr-2" />
|
||||
{t("planning:createEvent")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (isReadOnly) {
|
||||
toast.add(
|
||||
{
|
||||
title: t("common:error"),
|
||||
description:
|
||||
"Vous êtes en mode lecture seule. Vous ne pouvez pas importer de calendrier.",
|
||||
type: "error",
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
return;
|
||||
}
|
||||
setIsImportModalOpen(true);
|
||||
}}
|
||||
variant="secondary"
|
||||
className="w-full mt-2"
|
||||
disabled={isReadOnly}
|
||||
>
|
||||
<FolderInputIcon className="w-5 h-5 mr-2" />
|
||||
{t("planning:importPlanning")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handleExportICS}
|
||||
disabled={!tabloEvents || tabloEvents.length === 0}
|
||||
variant="outline"
|
||||
className="w-full mt-2"
|
||||
title={t("planning:exportICS")}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
{t("planning:export")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (isReadOnly) {
|
||||
toast.add(
|
||||
{
|
||||
title: t("common:error"),
|
||||
description:
|
||||
"Vous êtes en mode lecture seule. Vous ne pouvez pas créer d'événement.",
|
||||
type: "error",
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (selectedTabloId === "all") {
|
||||
navigate(`/planning/create?date=${currentDate.toISOString()}`);
|
||||
} else {
|
||||
navigate(
|
||||
`/planning/create?tablo_id=${selectedTabloId}&date=${currentDate.toISOString()}`
|
||||
);
|
||||
}
|
||||
}}
|
||||
className="w-full bg-[#804EEC] hover:bg-[#6f3fd4] text-white"
|
||||
disabled={isReadOnly}
|
||||
>
|
||||
<PlusIcon className="w-5 h-5 mr-2" />
|
||||
{t("planning:createEvent")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (isReadOnly) {
|
||||
toast.add(
|
||||
{
|
||||
title: t("common:error"),
|
||||
description:
|
||||
"Vous êtes en mode lecture seule. Vous ne pouvez pas importer de calendrier.",
|
||||
type: "error",
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
return;
|
||||
}
|
||||
setIsImportModalOpen(true);
|
||||
}}
|
||||
variant="secondary"
|
||||
className="w-full mt-2"
|
||||
disabled={isReadOnly}
|
||||
>
|
||||
<FolderInputIcon className="w-5 h-5 mr-2" />
|
||||
{t("planning:importPlanning")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handleExportICS}
|
||||
disabled={!tabloEvents || tabloEvents.length === 0}
|
||||
variant="outline"
|
||||
className="w-full mt-2"
|
||||
title={t("planning:exportICS")}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
{t("planning:export")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Mini Calendar */}
|
||||
<div className="p-4 border-t border-border">
|
||||
<div className="text-sm font-medium text-foreground mb-3">
|
||||
{monthNames[currentDate.getMonth()]} {currentDate.getFullYear()}
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-1 text-xs">
|
||||
{dayNamesShort.map((day) => (
|
||||
<div key={day} className="text-center text-muted-foreground p-1">
|
||||
{day.slice(0, 1)}
|
||||
</div>
|
||||
))}
|
||||
{getDaysInMonth(currentDate).map((day, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`text-center p-1 cursor-pointer rounded ${
|
||||
day ? "hover:bg-muted" : ""
|
||||
} ${
|
||||
day && formatDate(day) === formatDate(new Date())
|
||||
? "bg-[#804EEC] text-white"
|
||||
: day
|
||||
? "text-foreground"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (day) {
|
||||
navigateToCreateEvent(day, selectedTabloId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{day ? day.getDate() : ""}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="bg-card border-b border-border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<TypographyH3>{t("planning:title")}</TypographyH3>
|
||||
<Button
|
||||
onClick={goToToday}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-[#804EEC] text-[#804EEC] hover:bg-[#804EEC]/10"
|
||||
>
|
||||
{t("planning:today")}
|
||||
</Button>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button onClick={() => navigateDate(-1)} className="p-2 hover:bg-muted rounded">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button onClick={() => navigateDate(1)} className="p-2 hover:bg-muted rounded">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<TypographyH4>{getViewTitle()}</TypographyH4>
|
||||
{/* Mini Calendar */}
|
||||
<div className="p-4 border-t border-border">
|
||||
<div className="text-sm font-medium text-foreground mb-3">
|
||||
{monthNames[currentDate.getMonth()]} {currentDate.getFullYear()}
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-1 text-xs">
|
||||
{dayNamesShort.map((day) => (
|
||||
<div key={day} className="text-center text-muted-foreground p-1">
|
||||
{day.slice(0, 1)}
|
||||
</div>
|
||||
))}
|
||||
{getDaysInMonth(currentDate).map((day, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`text-center p-1 cursor-pointer rounded ${
|
||||
day ? "hover:bg-muted" : ""
|
||||
} ${
|
||||
day && formatDate(day) === formatDate(new Date())
|
||||
? "bg-[#804EEC] text-white"
|
||||
: day
|
||||
? "text-foreground"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (day) {
|
||||
navigateToCreateEvent(day, selectedTabloId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{day ? day.getDate() : ""}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
onClick={() => setIsWebcalModalOpen(true)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
title={t("planning:syncCalendar")}
|
||||
>
|
||||
<RefreshCcw className="w-4 h-4 mr-1" />
|
||||
{t("planning:sync")}
|
||||
</Button>
|
||||
<div className="flex bg-muted rounded-lg p-1">
|
||||
{(["month", "week", "day"] as ViewType[]).map((view) => (
|
||||
<button
|
||||
key={view}
|
||||
onClick={() => changeView(view)}
|
||||
title={t(`planning:views.${view}Title`)}
|
||||
className={`px-3 py-1.5 text-sm rounded-md transition-colors capitalize ${
|
||||
currentView === view
|
||||
? "bg-white dark:bg-gray-800 text-[#804EEC] shadow-sm font-semibold"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{t(`planning:views.${view}`)}
|
||||
<span className="ml-1 text-xs opacity-60">
|
||||
{t(`planning:views.${view}Short`)}
|
||||
</span>
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="bg-card border-b border-border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<TypographyH3>{t("planning:title")}</TypographyH3>
|
||||
<Button
|
||||
onClick={goToToday}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-[#804EEC] text-[#804EEC] hover:bg-[#804EEC]/10"
|
||||
>
|
||||
{t("planning:today")}
|
||||
</Button>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button onClick={() => navigateDate(-1)} className="p-2 hover:bg-muted rounded">
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
))}
|
||||
<button onClick={() => navigateDate(1)} className="p-2 hover:bg-muted rounded">
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<TypographyH4>{getViewTitle()}</TypographyH4>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
onClick={() => setIsWebcalModalOpen(true)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
title={t("planning:syncCalendar")}
|
||||
>
|
||||
<RefreshCcw className="w-4 h-4 mr-1" />
|
||||
{t("planning:sync")}
|
||||
</Button>
|
||||
<div className="flex bg-muted rounded-lg p-1">
|
||||
{(["month", "week", "day"] as ViewType[]).map((view) => (
|
||||
<button
|
||||
key={view}
|
||||
onClick={() => changeView(view)}
|
||||
title={t(`planning:views.${view}Title`)}
|
||||
className={`px-3 py-1.5 text-sm rounded-md transition-colors capitalize ${
|
||||
currentView === view
|
||||
? "bg-white dark:bg-gray-800 text-[#804EEC] shadow-sm font-semibold"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{t(`planning:views.${view}`)}
|
||||
<span className="ml-1 text-xs opacity-60">
|
||||
{t(`planning:views.${view}Short`)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Calendar Views */}
|
||||
<div className="flex-1 p-4">
|
||||
{tabloEventsLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<img
|
||||
src="/icon.jpg"
|
||||
alt="Loading..."
|
||||
className="animate-spin rounded-full h-8 w-8 object-cover"
|
||||
/>
|
||||
<span className="ml-2 text-muted-foreground">{t("planning:loadingEvents")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{currentView === "month" && renderMonthView()}
|
||||
{currentView === "week" && renderWeekView()}
|
||||
{currentView === "day" && renderDayView()}
|
||||
</>
|
||||
)}
|
||||
{/* Calendar Views */}
|
||||
<div className="flex-1 p-4">
|
||||
{tabloEventsLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<img
|
||||
src="/icon.jpg"
|
||||
alt="Loading..."
|
||||
className="animate-spin rounded-full h-8 w-8 object-cover"
|
||||
/>
|
||||
<span className="ml-2 text-muted-foreground">{t("planning:loadingEvents")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{currentView === "month" && renderMonthView()}
|
||||
{currentView === "week" && renderWeekView()}
|
||||
{currentView === "day" && renderDayView()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<EventModal
|
||||
mode="create"
|
||||
isOpen={isCreateEventOpen}
|
||||
onClose={() => setIsCreateEventOpen(false)}
|
||||
defaultTabloId={selectedTabloId !== "all" ? selectedTabloId : undefined}
|
||||
defaultDate={currentDate}
|
||||
/>
|
||||
|
||||
<Outlet />
|
||||
|
||||
{isImportModalOpen && <ImportICSModal onClose={() => setIsImportModalOpen(false)} />}
|
||||
|
||||
<WebcalModal open={isWebcalModalOpen} onOpenChange={setIsWebcalModalOpen} />
|
||||
{isWebcalModalOpen && (
|
||||
<WebcalModal open={isWebcalModalOpen} onOpenChange={setIsWebcalModalOpen} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue