commit
cdd6180ecf
33 changed files with 11251 additions and 1742 deletions
|
|
@ -6,7 +6,11 @@ import type { StreamChat } from "stream-chat";
|
|||
import { config } from "./config.js";
|
||||
import type { Tables } from "./database.types.ts";
|
||||
import { writeCalendarFileToR2 } from "./helpers.js";
|
||||
import { authMiddleware, r2Middleware, streamChatMiddleware } from "./middleware.js";
|
||||
import {
|
||||
authMiddleware,
|
||||
r2Middleware,
|
||||
streamChatMiddleware,
|
||||
} from "./middleware.js";
|
||||
import { generateToken } from "./token.js";
|
||||
import { transporter } from "./transporter.js";
|
||||
import type { EventInsertInTablo, TabloInsert } from "./types.ts";
|
||||
|
|
@ -166,7 +170,9 @@ tabloRouter.post("/create-and-invite", async (c) => {
|
|||
const { data: insertedTablo, error } = await supabase
|
||||
.from("tablos")
|
||||
.insert({
|
||||
name: `${invitedUserDataTyped.name || "Invité"} / ${ownerDataTyped.name || "Propriétaire"}`,
|
||||
name: `${invitedUserDataTyped.name || "Invité"} / ${
|
||||
ownerDataTyped.name || "Propriétaire"
|
||||
}`,
|
||||
color: "bg-blue-500",
|
||||
status: "todo",
|
||||
owner_id: ownerId,
|
||||
|
|
@ -184,20 +190,22 @@ tabloRouter.post("/create-and-invite", async (c) => {
|
|||
}
|
||||
|
||||
// Grant access to the current user (invited user) as a non-admin member
|
||||
const { error: tabloAccessError } = await supabase.from("tablo_access").insert(
|
||||
{
|
||||
tablo_id: tabloData.id,
|
||||
user_id: user.id,
|
||||
// ** IMPORTANT **
|
||||
is_admin: false,
|
||||
// -------------
|
||||
is_active: true,
|
||||
granted_by: ownerId,
|
||||
}
|
||||
// {
|
||||
// onConflict: "tablo_id, user_id",
|
||||
// }
|
||||
);
|
||||
const { error: tabloAccessError } = await supabase
|
||||
.from("tablo_access")
|
||||
.insert(
|
||||
{
|
||||
tablo_id: tabloData.id,
|
||||
user_id: user.id,
|
||||
// ** IMPORTANT **
|
||||
is_admin: false,
|
||||
// -------------
|
||||
is_active: true,
|
||||
granted_by: ownerId,
|
||||
}
|
||||
// {
|
||||
// onConflict: "tablo_id, user_id",
|
||||
// }
|
||||
);
|
||||
|
||||
if (tabloAccessError) {
|
||||
console.error("tabloAccessError", tabloAccessError);
|
||||
|
|
@ -290,7 +298,8 @@ tabloRouter.patch("/update", async (c) => {
|
|||
|
||||
const updatedTablo = update as Tables<"tablos">;
|
||||
|
||||
const isUpdatingName = tablo.name !== undefined && tablo.name !== updatedTablo.name;
|
||||
const isUpdatingName =
|
||||
tablo.name !== undefined && tablo.name !== updatedTablo.name;
|
||||
|
||||
if (error) {
|
||||
return c.json({ error: error.message }, 500);
|
||||
|
|
@ -335,7 +344,10 @@ tabloRouter.delete("/delete", async (c) => {
|
|||
.single();
|
||||
|
||||
if (accessError || !tabloAccess || !tabloAccess.is_admin) {
|
||||
return c.json({ error: "You are not authorized to delete this tablo" }, 403);
|
||||
return c.json(
|
||||
{ error: "You are not authorized to delete this tablo" },
|
||||
403
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
|
|
@ -376,7 +388,10 @@ tabloRouter.post("/invite", async (c) => {
|
|||
}
|
||||
|
||||
if (tablo.owner_id !== sender.id) {
|
||||
return c.json({ error: "You are not allowed to invite users to this tablo" }, 400);
|
||||
return c.json(
|
||||
{ error: "You are not allowed to invite users to this tablo" },
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
const { data: introConfigData, error: introError } = await supabase
|
||||
|
|
@ -410,8 +425,8 @@ tabloRouter.post("/invite", async (c) => {
|
|||
<p>Cliquez sur <a href="${
|
||||
config.XTABLO_URL
|
||||
}/join/${encodeURIComponent(tablo.name)}?token=${encodeURIComponent(
|
||||
token
|
||||
)}">ce lien</a> pour accepter l'invitation.</p>
|
||||
token
|
||||
)}">ce lien</a> pour accepter l'invitation.</p>
|
||||
<br>
|
||||
<p>Cordialement.</p>
|
||||
`,
|
||||
|
|
@ -447,15 +462,17 @@ tabloRouter.post("/join", async (c) => {
|
|||
|
||||
const { id: invite_id, tablo_id, invited_by } = inviteData;
|
||||
|
||||
const { error: tabloAccessError } = await supabase.from("tablo_access").insert({
|
||||
tablo_id,
|
||||
user_id: joiner.id,
|
||||
// ** IMPORTANT **
|
||||
is_admin: false,
|
||||
// -------------
|
||||
is_active: true,
|
||||
granted_by: invited_by,
|
||||
});
|
||||
const { error: tabloAccessError } = await supabase
|
||||
.from("tablo_access")
|
||||
.insert({
|
||||
tablo_id,
|
||||
user_id: joiner.id,
|
||||
// ** IMPORTANT **
|
||||
is_admin: false,
|
||||
// -------------
|
||||
is_active: true,
|
||||
granted_by: invited_by,
|
||||
});
|
||||
|
||||
if (tabloAccessError) {
|
||||
console.error("tabloAccessError", tabloAccessError);
|
||||
|
|
@ -471,7 +488,7 @@ tabloRouter.post("/join", async (c) => {
|
|||
console.error("error adding member to channel", error);
|
||||
}
|
||||
|
||||
return c.json({ message: "Tablo joined successfully" });
|
||||
return c.json({ tablo_id });
|
||||
});
|
||||
|
||||
tabloRouter.get("/members/:tablo_id", async (c) => {
|
||||
|
|
|
|||
7
apps/external/.env.production
vendored
Normal file
7
apps/external/.env.production
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
VITE_SUPABASE_URL=https://mhcafqvzbrrwvahpvvzd.supabase.co
|
||||
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im1oY2FmcXZ6YnJyd3ZhaHB2dnpkIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDEyNDEzMjEsImV4cCI6MjA1NjgxNzMyMX0.Otxn5BWCPD2ABlMM59hCgeur9Tf_Q7PndAbTkqXDPtM
|
||||
|
||||
VITE_SUPABASE_ID=mhcafqvzbrrwvahpvvzd
|
||||
VITE_STREAM_CHAT_API_KEY="t5vvvddteapa"
|
||||
|
||||
VITE_API_URL=https://xablo-api-636270553187.europe-west1.run.app
|
||||
2
apps/external/biome.json
vendored
2
apps/external/biome.json
vendored
|
|
@ -3,7 +3,7 @@
|
|||
"vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": false },
|
||||
"files": {
|
||||
"ignoreUnknown": true,
|
||||
"includes": ["src/**/*", "*.{ts,tsx,js,jsx,json}"]
|
||||
"includes": ["src/**/*", "*.{tsx,js,jsx,json}", "vite.config.ts"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
|
|
|
|||
5
apps/external/package.json
vendored
5
apps/external/package.json
vendored
|
|
@ -4,8 +4,8 @@
|
|||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "tsc -b && vite build",
|
||||
"dev": "vite dev --port 5174",
|
||||
"build": "tsc -b && vite build --mode production",
|
||||
"deploy": "wrangler deploy",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "biome check .",
|
||||
|
|
@ -17,6 +17,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.2.5",
|
||||
"@cloudflare/vite-plugin": "^1.9.4",
|
||||
"@tailwindcss/vite": "^4.0.14",
|
||||
"@types/react": "19.0.10",
|
||||
"@types/react-dom": "19.0.4",
|
||||
|
|
|
|||
8
apps/external/src/EmbeddedBookingPage.tsx
vendored
8
apps/external/src/EmbeddedBookingPage.tsx
vendored
|
|
@ -213,11 +213,7 @@ export function EmbeddedBookingPage() {
|
|||
|
||||
const shortUserId = userInfo?.substring(userInfo.lastIndexOf("-") + 1);
|
||||
|
||||
const { data: publicSlots } = usePublicSlots(
|
||||
api,
|
||||
shortUserId || "",
|
||||
eventTypeStandardName || ""
|
||||
);
|
||||
const { data: publicSlots } = usePublicSlots(api, shortUserId || "", eventTypeStandardName || "");
|
||||
|
||||
const { mutateAsync: createTabloWithOwner } = useCreateTabloWithOwner(api, () => {
|
||||
handleCloseModal();
|
||||
|
|
@ -497,7 +493,7 @@ export function EmbeddedBookingPage() {
|
|||
|
||||
{/* Event Type Info */}
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-bold mb-3">{eventType?.name || "Type d'événement"}</h3>
|
||||
<h3 className="text-xl font-bold mb-3">{eventType?.name || "Type d'appel"}</h3>
|
||||
|
||||
{eventType?.description && (
|
||||
<TypographyMuted className={twMerge("mb-6 text-sm leading-relaxed", mutedTxtColor)}>
|
||||
|
|
|
|||
84
apps/external/src/FloatingBookingWidget.tsx
vendored
84
apps/external/src/FloatingBookingWidget.tsx
vendored
|
|
@ -119,29 +119,31 @@ export function FloatingBookingWidget() {
|
|||
const eventTypeStandardName = params.eventTypeStandardName as string;
|
||||
const shortUserId = userInfo?.substring(userInfo.lastIndexOf("-") + 1);
|
||||
|
||||
// Get variants from URL params with fallback to purple
|
||||
// Get view mode and variants from URL params
|
||||
const view = searchParams.get("view") || "default"; // 'button', 'modal', or 'default'
|
||||
const buttonVariant = (searchParams.get("buttonVariant") as ColorVariant) || "purple";
|
||||
|
||||
// Get color schemes based on variants
|
||||
const btnColors = buttonColors[buttonVariant];
|
||||
|
||||
const { data: publicSlots } = usePublicSlots(
|
||||
api,
|
||||
shortUserId || "",
|
||||
eventTypeStandardName || ""
|
||||
);
|
||||
const { data: publicSlots } = usePublicSlots(api, shortUserId || "", eventTypeStandardName || "");
|
||||
|
||||
const { mutateAsync: createTabloWithOwner } = useCreateTabloWithOwner(api, () => {
|
||||
handleCloseModal();
|
||||
setIsWidgetOpen(false);
|
||||
if (view === "modal") {
|
||||
// Send message to parent to close the modal
|
||||
window.parent.postMessage("xtablo:close", "*");
|
||||
} else {
|
||||
setIsWidgetOpen(false);
|
||||
}
|
||||
});
|
||||
|
||||
const userProfile = publicSlots?.user;
|
||||
const eventType = publicSlots?.eventType;
|
||||
const slotsData = publicSlots?.slots || {};
|
||||
|
||||
// Widget state
|
||||
const [isWidgetOpen, setIsWidgetOpen] = useState(false);
|
||||
// Widget state - auto-open if in modal view
|
||||
const [isWidgetOpen, setIsWidgetOpen] = useState(view === "modal");
|
||||
|
||||
// Calendar state
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
|
|
@ -370,27 +372,61 @@ export function FloatingBookingWidget() {
|
|||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 pointer-events-none">
|
||||
{/* Floating Button */}
|
||||
<div className="fixed bottom-6 right-6 z-50 pointer-events-auto">
|
||||
// If view is 'button', only show the button
|
||||
if (view === "button") {
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center">
|
||||
<Button
|
||||
size="lg"
|
||||
className={twMerge(
|
||||
"rounded-full h-14 w-14 shadow-lg hover:shadow-xl transition-all duration-200",
|
||||
btnColors.floating,
|
||||
isWidgetOpen && "scale-0 opacity-0"
|
||||
"rounded-full h-14 w-14 shadow-lg hover:shadow-xl border-0 transition-all duration-200",
|
||||
btnColors.floating
|
||||
)}
|
||||
onClick={() => setIsWidgetOpen(true)}
|
||||
onClick={() => window.parent.postMessage("xtablo:open", "*")}
|
||||
>
|
||||
<CalendarIcon className="w-6 h-6" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 pointer-events-none">
|
||||
{/* Backdrop for modal view */}
|
||||
{view === "modal" && isWidgetOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm pointer-events-auto animate-in fade-in duration-200"
|
||||
onClick={() => window.parent.postMessage("xtablo:close", "*")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Floating Button - only show in default view */}
|
||||
{view === "default" && (
|
||||
<div className="fixed bottom-6 right-6 z-50 pointer-events-auto">
|
||||
<Button
|
||||
size="lg"
|
||||
className={twMerge(
|
||||
"rounded-full h-14 w-14 shadow-lg hover:shadow-xl transition-all duration-200",
|
||||
btnColors.floating,
|
||||
isWidgetOpen && "scale-0 opacity-0"
|
||||
)}
|
||||
onClick={() => setIsWidgetOpen(true)}
|
||||
>
|
||||
<CalendarIcon className="w-6 h-6" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Floating Widget Popup */}
|
||||
{isWidgetOpen && (
|
||||
<div className="fixed bottom-6 right-6 z-50 w-[450px] max-h-[650px] bg-white dark:bg-gray-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-gray-700 flex flex-col overflow-hidden animate-in slide-in-from-bottom-4 duration-300 pointer-events-auto">
|
||||
<div
|
||||
className={twMerge(
|
||||
"z-50 w-[450px] max-h-[650px] bg-white dark:bg-gray-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-gray-700 flex flex-col overflow-hidden pointer-events-auto",
|
||||
view === "modal"
|
||||
? "fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 animate-in fade-in zoom-in-95 duration-200"
|
||||
: "fixed bottom-6 right-6 animate-in slide-in-from-bottom-4 duration-300"
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700 flex items-start justify-between">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
|
|
@ -407,7 +443,7 @@ export function FloatingBookingWidget() {
|
|||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white text-sm truncate">
|
||||
{eventType?.name || "Type d'événement"}
|
||||
{eventType?.name || "Type d'appel"}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||
{userProfile?.name || "Professionnel"}
|
||||
|
|
@ -419,8 +455,12 @@ export function FloatingBookingWidget() {
|
|||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => {
|
||||
setIsWidgetOpen(false);
|
||||
setSelectedDate(null);
|
||||
if (view === "modal") {
|
||||
window.parent.postMessage("xtablo:close", "*");
|
||||
} else {
|
||||
setIsWidgetOpen(false);
|
||||
setSelectedDate(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<XIcon className="w-4 h-4" />
|
||||
|
|
|
|||
1
apps/external/turbo.json
vendored
1
apps/external/turbo.json
vendored
|
|
@ -9,4 +9,3 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
46
apps/external/vite.config.ts
vendored
46
apps/external/vite.config.ts
vendored
|
|
@ -1,26 +1,44 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
||||
import { cloudflare } from "@cloudflare/vite-plugin";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { dirname, resolve } from "path";
|
||||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { defineConfig } from "vite";
|
||||
import { defineConfig, PluginOption } from "vite";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss(), tsconfigPaths()],
|
||||
server: {
|
||||
cors: false,
|
||||
port: 5174,
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: resolve(__dirname, "index.html"),
|
||||
},
|
||||
export default defineConfig(({ mode }) => {
|
||||
const plugins: PluginOption[] = [
|
||||
react(),
|
||||
// visualizer() as PluginOption,
|
||||
tailwindcss(),
|
||||
tsconfigPaths(),
|
||||
];
|
||||
|
||||
// Only include cloudflare plugin when not in test mode
|
||||
if (mode !== "test" && process.env.VITEST !== "true") {
|
||||
plugins.push(cloudflare());
|
||||
}
|
||||
|
||||
return {
|
||||
plugins,
|
||||
server: {
|
||||
cors: false,
|
||||
},
|
||||
},
|
||||
define: process.env.VITEST
|
||||
? {
|
||||
"import.meta.env.VITE_SUPABASE_URL": JSON.stringify("https://test.supabase.co"),
|
||||
"import.meta.env.VITE_SUPABASE_ANON_KEY": JSON.stringify("test-anon-key"),
|
||||
}
|
||||
: undefined,
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "jsdom",
|
||||
setupFiles: "./src/setupTests.ts",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
|||
9363
apps/external/worker-configuration.d.ts
vendored
Normal file
9363
apps/external/worker-configuration.d.ts
vendored
Normal file
File diff suppressed because it is too large
Load diff
5
apps/external/wrangler.toml
vendored
5
apps/external/wrangler.toml
vendored
|
|
@ -12,6 +12,5 @@ enabled = true
|
|||
[vars]
|
||||
PYTHON_VERSION = "3.11.5"
|
||||
|
||||
[[routes]]
|
||||
pattern = "embed.xtablo.com"
|
||||
custom_domain = true
|
||||
[env.production]
|
||||
route = { pattern = "embed.xtablo.com", custom_domain = true }
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"dev": "vite dev --port 5173",
|
||||
"build": "tsc -b && vite build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "biome check .",
|
||||
|
|
|
|||
|
|
@ -6,18 +6,20 @@ interface CustomChannelHeaderProps {
|
|||
tablos: UserTablo[];
|
||||
onToggleChannelList?: () => void;
|
||||
isChannelListExpanded?: boolean;
|
||||
showToggleButton?: boolean;
|
||||
}
|
||||
|
||||
export const CustomChannelHeader = ({
|
||||
tablos,
|
||||
onToggleChannelList,
|
||||
isChannelListExpanded = false,
|
||||
showToggleButton = true,
|
||||
}: CustomChannelHeaderProps) => {
|
||||
const { channel } = useChannelStateContext();
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
{onToggleChannelList && (
|
||||
{showToggleButton && onToggleChannelList && (
|
||||
<button
|
||||
onClick={onToggleChannelList}
|
||||
className="mr-2 p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
|
|
|
|||
|
|
@ -61,17 +61,49 @@ export function EmbedConfigModal({ isOpen, onClose, buildPublicLink }: EmbedConf
|
|||
|
||||
if (embedConfig.embedType === "floating") {
|
||||
return `<!-- Xtablo Floating Widget -->
|
||||
<div id="xtablo-widget-container"></div>
|
||||
<div id="xtablo-widget-root"></div>
|
||||
<script>
|
||||
(function() {
|
||||
var container = document.getElementById('xtablo-widget-container');
|
||||
var iframe = document.createElement('iframe');
|
||||
iframe.src = '${embedUrl}';
|
||||
iframe.style.cssText = 'position: fixed; bottom: 0; right: 0; width: 100%; height: 100%; border: none; z-index: 999999; background: transparent;';
|
||||
iframe.setAttribute('frameborder', '0');
|
||||
iframe.setAttribute('allow', 'clipboard-write');
|
||||
var root = document.getElementById('xtablo-widget-root');
|
||||
var isOpen = false;
|
||||
var modal = null;
|
||||
|
||||
container.appendChild(iframe);
|
||||
// Create floating button
|
||||
var button = document.createElement('div');
|
||||
button.style.cssText = 'position: fixed; bottom: 24px; right: 24px; z-index: 999998; cursor: pointer; width: 60px; height: 60px;';
|
||||
button.innerHTML = '<iframe src="${embedUrl}&view=button" style="width: 100%; height: 100%; border: none; border-radius: 50%;" frameborder="0" scrolling="no"></iframe>';
|
||||
|
||||
// Create modal container (hidden by default)
|
||||
modal = document.createElement('div');
|
||||
modal.style.cssText = 'position: fixed; bottom: 0; right: 0; width: 100%; height: 100%; z-index: 999999; display: none; pointer-events: none;';
|
||||
|
||||
var modalIframe = document.createElement('iframe');
|
||||
modalIframe.src = '${embedUrl}&view=modal';
|
||||
modalIframe.style.cssText = 'position: absolute; bottom: 0; right: 0; width: 100%; height: 100%; border: none; background: transparent; pointer-events: auto;';
|
||||
modalIframe.setAttribute('frameborder', '0');
|
||||
modalIframe.setAttribute('allow', 'clipboard-write');
|
||||
|
||||
modal.appendChild(modalIframe);
|
||||
|
||||
// Toggle modal on button click
|
||||
button.addEventListener('click', function() {
|
||||
isOpen = !isOpen;
|
||||
modal.style.display = isOpen ? 'block' : 'none';
|
||||
});
|
||||
|
||||
// Listen for close messages from iframe
|
||||
window.addEventListener('message', function(event) {
|
||||
if (event.data === 'xtablo:close') {
|
||||
isOpen = false;
|
||||
modal.style.display = 'none';
|
||||
} else if (event.data === 'xtablo:open') {
|
||||
isOpen = true;
|
||||
modal.style.display = 'block';
|
||||
}
|
||||
});
|
||||
|
||||
root.appendChild(button);
|
||||
root.appendChild(modal);
|
||||
})();
|
||||
</script>`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
import { useState } from "react";
|
||||
import { match } from "ts-pattern";
|
||||
import { EventType, EventTypeConfig, useEventTypes } from "../hooks/event-types";
|
||||
import { isDev } from "../lib/env";
|
||||
import { useUser } from "../providers/UserStoreProvider";
|
||||
|
||||
export function EventTypeCard({
|
||||
|
|
@ -41,16 +42,16 @@ export function EventTypeCard({
|
|||
|
||||
const shortUserId = user.id.substring(0, 6);
|
||||
// Construct the public booking URL
|
||||
const baseUrl = window.location.origin;
|
||||
const baseUrl = isDev ? "http://localhost:5174" : "https://embed.xtablo.com";
|
||||
return match(type)
|
||||
.with("embed", () => {
|
||||
return `${baseUrl}/external/?mode=embed&userInfo=${sanitizedUserName}-${shortUserId}&eventTypeStandardName=${standardName}`;
|
||||
return `${baseUrl}/embed/${sanitizedUserName}-${shortUserId}/${standardName}`;
|
||||
})
|
||||
.with("floating", () => {
|
||||
return `${baseUrl}/external/?mode=widget&userInfo=${sanitizedUserName}-${shortUserId}&eventTypeStandardName=${standardName}`;
|
||||
return `${baseUrl}/widget/${sanitizedUserName}-${shortUserId}/${standardName}`;
|
||||
})
|
||||
.with("normal", () => {
|
||||
return `${baseUrl}/book/${sanitizedUserName}-${shortUserId}/${standardName}`;
|
||||
return `${window.location.origin}/book/${sanitizedUserName}-${shortUserId}/${standardName}`;
|
||||
})
|
||||
.exhaustive();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ export function EventTypeModal({
|
|||
<DialogContent className="max-w-xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingEventType ? "Modifier le type d'événement" : "Nouveau type d'événement"}
|
||||
{editingEventType ? "Modifier le Type d'appel" : "Nouveau Type d'appel"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-6">
|
||||
|
|
@ -47,7 +47,7 @@ export function EventTypeModal({
|
|||
<div className="space-y-2">
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
Nom du type d'événement <span className="text-destructive">*</span>
|
||||
Nom du type d'appel <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
type="text"
|
||||
|
|
@ -64,7 +64,7 @@ export function EventTypeModal({
|
|||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={2}
|
||||
required
|
||||
placeholder="Décrivez ce type d'événement et son objectif..."
|
||||
placeholder="Décrivez ce Type d'appel et son objectif..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -98,9 +98,7 @@ export function EventTypeModal({
|
|||
max={60}
|
||||
step={5}
|
||||
/>
|
||||
<FieldDescription>
|
||||
Temps de battement avant et après l'événement
|
||||
</FieldDescription>
|
||||
<FieldDescription>Temps de battement avant et après l'appel</FieldDescription>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -192,7 +190,7 @@ export function EventTypeModal({
|
|||
>
|
||||
<Label>Prix (€)</Label>
|
||||
<Description>
|
||||
Prix de ce type d'événement. Laissez à 0 pour gratuit.
|
||||
Prix de ce type d'appel. Laissez à 0 pour gratuit.
|
||||
</Description>
|
||||
</NumberField>
|
||||
</div> */}
|
||||
|
|
@ -210,9 +208,9 @@ export function EventTypeModal({
|
|||
onChange={(isActive) => setFormData({ ...formData, isActive })}
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium">Type d'événement actif</div>
|
||||
<div className="font-medium">Type d'appel actif</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Les clients peuvent réserver ce type d'événement
|
||||
Les clients peuvent réserver ce type d'appel
|
||||
</div>
|
||||
</div>
|
||||
</Checkbox>
|
||||
|
|
|
|||
|
|
@ -18,11 +18,10 @@ import {
|
|||
Circle,
|
||||
ConstructionIcon,
|
||||
Kanban,
|
||||
LayoutDashboardIcon,
|
||||
ListCheckIcon,
|
||||
LogOutIcon,
|
||||
MessageCircleIcon,
|
||||
MinusIcon,
|
||||
PanelsTopLeft,
|
||||
PlusIcon,
|
||||
SendIcon,
|
||||
SettingsIcon,
|
||||
|
|
@ -181,6 +180,14 @@ export function UserMenuPopover({ isCollapsed }: { isCollapsed: boolean }) {
|
|||
/>
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink to="/availabilities">
|
||||
<MenuDropdownItem
|
||||
icon={<CalendarIcon className="w-5 h-5" aria-hidden="true" />}
|
||||
label="Disponibilités"
|
||||
variant="default"
|
||||
/>
|
||||
</RouterLink>
|
||||
|
||||
<MenuSeparator />
|
||||
<div className="flex flex-row my-2 ml-1 items-center">
|
||||
<ThemeSwitcher />
|
||||
|
|
@ -283,23 +290,13 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
|
|||
> = [
|
||||
{
|
||||
path: "/",
|
||||
label: "Dashboard",
|
||||
icon: <LayoutDashboardIcon className="w-5 h-5" />,
|
||||
label: "Tablos",
|
||||
icon: <PanelsTopLeft className="w-5 h-5" />,
|
||||
},
|
||||
{ isHorizontalBar: true },
|
||||
{
|
||||
path: "/event-types",
|
||||
label: "Types d'événements",
|
||||
icon: <ListCheckIcon className="w-5 h-5" />,
|
||||
},
|
||||
{
|
||||
path: "/availabilities",
|
||||
label: "Disponibilités",
|
||||
icon: <CalendarIcon className="w-5 h-5" />,
|
||||
},
|
||||
{
|
||||
path: "/bookings",
|
||||
label: "Réservations",
|
||||
path: "/events",
|
||||
label: "Mes Événements",
|
||||
icon: <CalendarCheckIcon className="w-5 h-5" />,
|
||||
},
|
||||
{
|
||||
|
|
|
|||
65
apps/main/src/components/TabloDiscussionSection.tsx
Normal file
65
apps/main/src/components/TabloDiscussionSection.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { CustomChannelHeader } from "@ui/components/CustomChannelHeader";
|
||||
import { UserTablo } from "@xtablo/shared/types/tablos.types";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Channel as StreamChannel } from "stream-chat";
|
||||
import { Channel, MessageInput, MessageList, useChatContext, Window } from "stream-chat-react";
|
||||
import ChatProvider from "../providers/ChatProvider";
|
||||
import { LoadingSpinner } from "./LoadingSpinner";
|
||||
|
||||
interface TabloDiscussionSectionProps {
|
||||
tablo: UserTablo;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
const TabloChat = ({ tablo }: { tablo: UserTablo }) => {
|
||||
const { client, setActiveChannel } = useChatContext();
|
||||
const [channel, setChannel] = useState<StreamChannel | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const initChannel = async () => {
|
||||
if (client && tablo.id) {
|
||||
const newChannel = client.channel("messaging", tablo.id);
|
||||
await newChannel.watch();
|
||||
setChannel(newChannel);
|
||||
setActiveChannel(newChannel);
|
||||
}
|
||||
};
|
||||
|
||||
initChannel();
|
||||
}, [client, tablo.id, setActiveChannel]);
|
||||
|
||||
if (!channel) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Channel channel={channel}>
|
||||
<Window>
|
||||
<CustomChannelHeader tablos={[tablo]} showToggleButton={false} />
|
||||
<MessageList />
|
||||
<MessageInput />
|
||||
</Window>
|
||||
</Channel>
|
||||
);
|
||||
};
|
||||
|
||||
export const TabloDiscussionSection = ({ tablo }: TabloDiscussionSectionProps) => {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold text-foreground">Discussion</h1>
|
||||
<p className="text-muted-foreground mt-1">Conversations liées à ce tablo</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 bg-card rounded-lg border border-border overflow-hidden min-h-0">
|
||||
<ChatProvider>
|
||||
<TabloChat tablo={tablo} />
|
||||
</ChatProvider>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
500
apps/main/src/components/TabloFilesSection.tsx
Normal file
500
apps/main/src/components/TabloFilesSection.tsx
Normal file
|
|
@ -0,0 +1,500 @@
|
|||
import { toast } from "@xtablo/shared";
|
||||
import { UserTablo } from "@xtablo/shared/types/tablos.types";
|
||||
import { Button } from "@xtablo/ui/components/button";
|
||||
import { DownloadIcon, Trash2Icon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { FileTrigger } from "react-aria-components";
|
||||
import {
|
||||
useCreateTabloFile,
|
||||
useDeleteTabloFile,
|
||||
useDownloadTabloFile,
|
||||
useTabloFileNames,
|
||||
} from "../hooks/tablo_data";
|
||||
|
||||
interface TabloFilesSectionProps {
|
||||
tablo: UserTablo;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) => {
|
||||
const {
|
||||
data: fileData,
|
||||
isLoading: filesLoading,
|
||||
error: filesError,
|
||||
} = useTabloFileNames(tablo.id);
|
||||
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [deletingFile, setDeletingFile] = useState<string | null>(null);
|
||||
const [downloadingFile, setDownloadingFile] = useState<string | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const createFile = useCreateTabloFile();
|
||||
const deleteFile = useDeleteTabloFile();
|
||||
const downloadFile = useDownloadTabloFile();
|
||||
|
||||
const handleFileSelect = (files: FileList | null) => {
|
||||
const file = files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Validate file size (20MB limit)
|
||||
const maxSize = 20 * 1024 * 1024; // 20MB in bytes
|
||||
if (file.size > maxSize) {
|
||||
setError("Le fichier ne peut pas dépasser 20MB");
|
||||
return;
|
||||
}
|
||||
|
||||
setError("");
|
||||
setSelectedFile(file);
|
||||
};
|
||||
|
||||
const handleFileUpload = async () => {
|
||||
if (!selectedFile || !tablo.id) return;
|
||||
|
||||
setIsUploading(true);
|
||||
try {
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
try {
|
||||
const content = e.target?.result as string;
|
||||
|
||||
await createFile.mutateAsync({
|
||||
tabloId: tablo.id,
|
||||
fileName: selectedFile.name,
|
||||
data: {
|
||||
content,
|
||||
contentType: selectedFile.type || "application/octet-stream",
|
||||
},
|
||||
});
|
||||
|
||||
// Reset upload state
|
||||
setSelectedFile(null);
|
||||
setIsUploading(false);
|
||||
} catch (uploadError) {
|
||||
setIsUploading(false);
|
||||
console.error("Upload error:", uploadError);
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
setIsUploading(false);
|
||||
toast.add(
|
||||
{
|
||||
title: "Erreur de lecture",
|
||||
description: "Impossible de lire le fichier sélectionné",
|
||||
type: "error",
|
||||
},
|
||||
{
|
||||
timeout: 5000,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// Read file as base64 data URL for binary files, or as text for text files
|
||||
if (selectedFile.type.startsWith("text/") || selectedFile.type === "application/json") {
|
||||
reader.readAsText(selectedFile);
|
||||
} else {
|
||||
reader.readAsDataURL(selectedFile);
|
||||
}
|
||||
} catch (error) {
|
||||
setIsUploading(false);
|
||||
console.error("Upload error:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const cancelFileUpload = () => {
|
||||
setSelectedFile(null);
|
||||
};
|
||||
|
||||
const handleDeleteFile = async (fileName: string) => {
|
||||
if (!tablo.id) return;
|
||||
|
||||
// Simple confirmation
|
||||
if (!window.confirm(`Êtes-vous sûr de vouloir supprimer le fichier "${fileName}" ?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeletingFile(fileName);
|
||||
try {
|
||||
await deleteFile.mutateAsync({
|
||||
tabloId: tablo.id,
|
||||
fileName,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Delete error:", error);
|
||||
} finally {
|
||||
setDeletingFile(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadFile = async (fileName: string) => {
|
||||
if (!tablo.id) return;
|
||||
|
||||
setDownloadingFile(fileName);
|
||||
try {
|
||||
await downloadFile.mutateAsync({
|
||||
tabloId: tablo.id,
|
||||
fileName,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Download error:", error);
|
||||
} finally {
|
||||
setDownloadingFile(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Fichiers</h1>
|
||||
<p className="text-muted-foreground mt-1">Gérez les fichiers attachés à ce tablo</p>
|
||||
</div>
|
||||
|
||||
{/* Error Banner */}
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg flex items-center space-x-2">
|
||||
<svg
|
||||
className="w-5 h-5 text-red-500 shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-red-700 dark:text-red-300 text-sm">{error}</span>
|
||||
<button
|
||||
onClick={() => setError("")}
|
||||
className="ml-auto text-red-500 hover:text-red-700 dark:hover:text-red-300"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File Upload Section - Only for Admins */}
|
||||
{isAdmin && (
|
||||
<div className="bg-card rounded-lg border border-border p-6">
|
||||
<div className="flex items-center space-x-2 mb-4">
|
||||
<svg
|
||||
className="w-5 h-5 text-green-600 dark:text-green-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
|
||||
/>
|
||||
</svg>
|
||||
<h3 className="text-lg font-semibold text-foreground">Ajouter un fichier</h3>
|
||||
</div>
|
||||
|
||||
{!selectedFile ? (
|
||||
<div className="space-y-3">
|
||||
<FileTrigger allowsMultiple={false} onSelect={handleFileSelect}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-center py-8 border-2 border-dashed border-gray-300 dark:border-gray-600 hover:border-blue-400 dark:hover:border-blue-500 bg-gray-50 dark:bg-gray-800/50 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors"
|
||||
>
|
||||
<div className="flex flex-col items-center space-y-2">
|
||||
<svg
|
||||
className="w-8 h-8 text-gray-400 dark:text-gray-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
<div className="text-center">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Cliquez pour sélectionner un fichier
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</FileTrigger>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-3 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-md">
|
||||
<div className="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-white text-sm font-medium">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{selectedFile.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{(selectedFile.size / 1024 / 1024).toFixed(2)} MB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFileUpload}
|
||||
disabled={isUploading}
|
||||
className="flex-1 px-4 py-2.5 text-sm font-medium text-white bg-green-600 hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed rounded-lg transition-colors flex items-center justify-center space-x-2 shadow-sm"
|
||||
>
|
||||
{isUploading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
<span>Ajout en cours...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
<span>Ajouter le fichier</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancelFileUpload}
|
||||
disabled={isUploading}
|
||||
className="px-4 py-2.5 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg transition-colors"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||
Taille maximale par fichier: 20MB
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File List */}
|
||||
<div className="bg-card rounded-lg border border-border p-6">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-4">
|
||||
Liste des fichiers
|
||||
{fileData?.fileNames && (
|
||||
<span className="ml-2 text-sm font-normal text-muted-foreground">
|
||||
({fileData.fileNames.length})
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
|
||||
{filesLoading ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500"></div>
|
||||
<span className="ml-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
Chargement des fichiers...
|
||||
</span>
|
||||
</div>
|
||||
) : filesError ? (
|
||||
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<div className="flex items-center space-x-2">
|
||||
<svg
|
||||
className="w-5 h-5 text-red-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm text-red-700 dark:text-red-300">
|
||||
Erreur lors du chargement des fichiers
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : fileData && fileData.fileNames && fileData.fileNames.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{fileData.fileNames.map((fileName, index) => {
|
||||
const fileExtension = fileName.split(".").pop()?.toLowerCase() || "";
|
||||
const isImage = ["jpg", "jpeg", "png", "gif", "webp", "svg"].includes(fileExtension);
|
||||
const isPdf = fileExtension === "pdf";
|
||||
const isText = ["txt", "md", "json", "csv"].includes(fileExtension);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center space-x-3 p-3 bg-muted rounded-lg hover:bg-muted/80 transition-colors group"
|
||||
>
|
||||
<button
|
||||
onClick={() => handleDownloadFile(fileName)}
|
||||
disabled={downloadingFile === fileName}
|
||||
className={`w-10 h-10 rounded-lg flex items-center justify-center text-white text-sm font-medium transition-all hover:scale-105 ${
|
||||
isImage
|
||||
? "bg-purple-500 hover:bg-purple-600"
|
||||
: isPdf
|
||||
? "bg-red-500 hover:bg-red-600"
|
||||
: isText
|
||||
? "bg-blue-500 hover:bg-blue-600"
|
||||
: "bg-gray-500 hover:bg-gray-600"
|
||||
} ${
|
||||
downloadingFile === fileName
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: "cursor-pointer"
|
||||
}`}
|
||||
title={`Télécharger ${fileName}`}
|
||||
>
|
||||
{downloadingFile === fileName ? (
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
|
||||
) : isImage ? (
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
) : isPdf ? (
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground truncate" title={fileName}>
|
||||
{fileName}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground uppercase">
|
||||
{fileExtension || "Fichier"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDownloadFile(fileName)}
|
||||
disabled={downloadingFile === fileName}
|
||||
className="p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:text-blue-400 dark:hover:bg-blue-900/20 transition-colors"
|
||||
aria-label={`Télécharger ${fileName}`}
|
||||
>
|
||||
{downloadingFile === fileName ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-500"></div>
|
||||
) : (
|
||||
<DownloadIcon className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDeleteFile(fileName)}
|
||||
disabled={deletingFile === fileName}
|
||||
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:text-red-400 dark:hover:bg-red-900/20 transition-colors"
|
||||
aria-label={`Supprimer ${fileName}`}
|
||||
>
|
||||
{deletingFile === fileName ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-red-500"></div>
|
||||
) : (
|
||||
<Trash2Icon className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<svg
|
||||
className="w-12 h-12 text-gray-300 dark:text-gray-600 mx-auto mb-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 5a2 2 0 012-2h2a2 2 0 012 2v0H8v0z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Aucun fichier dans ce tablo</p>
|
||||
{isAdmin && (
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
Ajoutez votre premier fichier ci-dessus
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,911 +0,0 @@
|
|||
import { toast } from "@xtablo/shared";
|
||||
import { TabloUpdate, UserTablo } from "@xtablo/shared/types/tablos.types";
|
||||
import { Button } from "@xtablo/ui/components/button";
|
||||
import { DownloadIcon, Trash2Icon } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { FileTrigger } from "react-aria-components";
|
||||
import { useInviteUser } from "../hooks/invite";
|
||||
import {
|
||||
useCreateTabloFile,
|
||||
useDeleteTabloFile,
|
||||
useDownloadTabloFile,
|
||||
useTabloFileNames,
|
||||
} from "../hooks/tablo_data";
|
||||
import { useTabloMembers } from "../hooks/tablos";
|
||||
import { useUser } from "../providers/UserStoreProvider";
|
||||
import { ClickOutside } from "./ClickOutside";
|
||||
import { ImageColorPicker } from "./ImageColorPicker";
|
||||
import { StatusPicker } from "./StatusPicker";
|
||||
|
||||
type StatusType = "todo" | "in_progress" | "done";
|
||||
|
||||
interface TabloModalProps {
|
||||
tablo: UserTablo | null;
|
||||
onEdit: (updatedTablo: TabloUpdate & { id: string }) => void;
|
||||
onClose: () => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export const TabloModal = ({ tablo, onClose, onEdit }: TabloModalProps) => {
|
||||
const currentUser = useUser();
|
||||
const isAdmin = tablo?.is_admin ?? false;
|
||||
|
||||
const [editData, setEditData] = useState<UserTablo | null>(tablo);
|
||||
const [isEditingName, setIsEditingName] = useState(false);
|
||||
|
||||
const [creationMode, setCreationMode] = useState<"image" | "color">("color");
|
||||
const [selectedColor, setSelectedColor] = useState(tablo?.color || "bg-blue-500");
|
||||
const [error, setError] = useState("");
|
||||
const { data: members } = useTabloMembers(tablo?.id ?? "");
|
||||
const [showMembers, setShowMembers] = useState(false);
|
||||
|
||||
const [inviteEmail, setInviteEmail] = useState("");
|
||||
const { mutate: inviteUser, isPending: isInvitingUser } = useInviteUser();
|
||||
|
||||
const {
|
||||
data: fileData,
|
||||
isLoading: filesLoading,
|
||||
error: filesError,
|
||||
} = useTabloFileNames(tablo?.id ?? "");
|
||||
const [showFiles, setShowFiles] = useState(false);
|
||||
|
||||
// File upload state
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [deletingFile, setDeletingFile] = useState<string | null>(null);
|
||||
const [downloadingFile, setDownloadingFile] = useState<string | null>(null);
|
||||
const createFile = useCreateTabloFile();
|
||||
const deleteFile = useDeleteTabloFile();
|
||||
const downloadFile = useDownloadTabloFile();
|
||||
|
||||
const handleSaveEdit = () => {
|
||||
if (editData && onEdit) {
|
||||
// Clear the unused field based on selection
|
||||
const updatedTablo: TabloUpdate & { id: string } = {
|
||||
id: editData.id,
|
||||
name: editData.name,
|
||||
//TODO: image: creationMode === "image" ? editData.image : null,
|
||||
color: creationMode === "color" ? selectedColor : null,
|
||||
status: editData.status,
|
||||
};
|
||||
onEdit(updatedTablo);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendInvite = () => {
|
||||
if (inviteEmail.trim()) {
|
||||
inviteUser({ email: inviteEmail, tablo_id: tablo?.id ?? "" });
|
||||
}
|
||||
};
|
||||
|
||||
const isEmailValid = (email: string): boolean => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
};
|
||||
|
||||
const handleFileSelect = (files: FileList | null) => {
|
||||
const file = files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Validate file size (20MB limit)
|
||||
const maxSize = 20 * 1024 * 1024; // 20MB in bytes
|
||||
if (file.size > maxSize) {
|
||||
setError("Le fichier ne peut pas dépasser 20MB");
|
||||
return;
|
||||
}
|
||||
|
||||
setError("");
|
||||
setSelectedFile(file);
|
||||
};
|
||||
|
||||
const handleFileUpload = async () => {
|
||||
if (!selectedFile || !tablo?.id) return;
|
||||
|
||||
setIsUploading(true);
|
||||
try {
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
try {
|
||||
const content = e.target?.result as string;
|
||||
|
||||
await createFile.mutateAsync({
|
||||
tabloId: tablo.id,
|
||||
fileName: selectedFile.name,
|
||||
data: {
|
||||
content,
|
||||
contentType: selectedFile.type || "application/octet-stream",
|
||||
},
|
||||
});
|
||||
|
||||
// Reset upload state
|
||||
setSelectedFile(null);
|
||||
setIsUploading(false);
|
||||
} catch (uploadError) {
|
||||
setIsUploading(false);
|
||||
console.error("Upload error:", uploadError);
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
setIsUploading(false);
|
||||
toast.add(
|
||||
{
|
||||
title: "Erreur de lecture",
|
||||
description: "Impossible de lire le fichier sélectionné",
|
||||
type: "error",
|
||||
},
|
||||
{
|
||||
timeout: 5000,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// Read file as base64 data URL for binary files, or as text for text files
|
||||
if (selectedFile.type.startsWith("text/") || selectedFile.type === "application/json") {
|
||||
reader.readAsText(selectedFile);
|
||||
} else {
|
||||
reader.readAsDataURL(selectedFile);
|
||||
}
|
||||
} catch (error) {
|
||||
setIsUploading(false);
|
||||
console.error("Upload error:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const cancelFileUpload = () => {
|
||||
setSelectedFile(null);
|
||||
};
|
||||
|
||||
const handleDeleteFile = async (fileName: string) => {
|
||||
if (!tablo?.id) return;
|
||||
|
||||
// Simple confirmation
|
||||
if (!window.confirm(`Êtes-vous sûr de vouloir supprimer le fichier "${fileName}" ?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeletingFile(fileName);
|
||||
try {
|
||||
await deleteFile.mutateAsync({
|
||||
tabloId: tablo.id,
|
||||
fileName,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Delete error:", error);
|
||||
} finally {
|
||||
setDeletingFile(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadFile = async (fileName: string) => {
|
||||
if (!tablo?.id) return;
|
||||
|
||||
setDownloadingFile(fileName);
|
||||
try {
|
||||
await downloadFile.mutateAsync({
|
||||
tabloId: tablo.id,
|
||||
fileName,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Download error:", error);
|
||||
} finally {
|
||||
setDownloadingFile(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (!tablo) return null;
|
||||
|
||||
const currentData = editData || tablo;
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey) && isAdmin) {
|
||||
handleSaveEdit();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onClose, handleSaveEdit, isAdmin]);
|
||||
|
||||
// Auto-focus name input when editing
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => {
|
||||
if (isEditingName && nameInputRef.current) {
|
||||
nameInputRef.current.focus();
|
||||
nameInputRef.current.select();
|
||||
}
|
||||
}, [isEditingName]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
||||
<ClickOutside onClickOutside={onClose}>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full min-w-[32rem] max-w-2xl max-h-[95vh] flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700 shrink-0">
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
{/* Tablo Color/Image Preview */}
|
||||
<div className="shrink-0">
|
||||
{tablo.image ? (
|
||||
<img
|
||||
src={tablo.image}
|
||||
alt={tablo.name}
|
||||
className="w-10 h-10 rounded-lg object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={`w-10 h-10 rounded-lg ${
|
||||
tablo.color || "bg-blue-500"
|
||||
} flex items-center justify-center`}
|
||||
>
|
||||
<span className="text-white font-bold text-sm">
|
||||
{tablo.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{isAdmin && isEditingName ? (
|
||||
<ClickOutside onClickOutside={() => setIsEditingName(false)}>
|
||||
<input
|
||||
ref={nameInputRef}
|
||||
type="text"
|
||||
value={editData?.name}
|
||||
onChange={(e) =>
|
||||
setEditData((prev) => (prev ? { ...prev, name: e.target.value } : null))
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
setIsEditingName(false);
|
||||
}
|
||||
}}
|
||||
className="text-xl font-bold text-gray-900 dark:text-white bg-transparent border-b-2 border-blue-500 focus:outline-none focus:border-blue-600 w-full"
|
||||
placeholder="Nom du tablo"
|
||||
/>
|
||||
</ClickOutside>
|
||||
) : (
|
||||
<div>
|
||||
<h2
|
||||
className={`text-xl font-bold text-gray-900 dark:text-white truncate ${
|
||||
isAdmin
|
||||
? "cursor-text hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||
: ""
|
||||
}`}
|
||||
onClick={isAdmin ? () => setIsEditingName(true) : undefined}
|
||||
title={tablo.name}
|
||||
>
|
||||
{tablo.name}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{isAdmin ? "Administrateur" : "Invité"} •{" "}
|
||||
{currentData.status === "todo" && "À faire"}
|
||||
{currentData.status === "in_progress" && "En cours"}
|
||||
{currentData.status === "done" && "Terminé"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Close Button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
title="Fermer (Échap)"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error Banner */}
|
||||
{error && (
|
||||
<div className="mx-6 mt-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg flex items-center space-x-2">
|
||||
<svg
|
||||
className="w-5 h-5 text-red-500 shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-red-700 dark:text-red-300 text-sm">{error}</span>
|
||||
<button
|
||||
onClick={() => setError("")}
|
||||
className="ml-auto text-red-500 hover:text-red-700 dark:hover:text-red-300"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content - Expandable */}
|
||||
<div className="flex-grow px-6 py-4 overflow-y-auto space-y-6">
|
||||
{!isAdmin ? (
|
||||
/* Read-only content */
|
||||
<div className="space-y-4 mb-4">
|
||||
{/* Tablo Preview */}
|
||||
<div className="relative h-48 rounded-lg overflow-hidden">
|
||||
{tablo.image ? (
|
||||
<img
|
||||
src={tablo.image}
|
||||
alt={tablo.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={`w-full h-full ${
|
||||
tablo.color || "bg-gray-400"
|
||||
} flex items-center justify-center`}
|
||||
>
|
||||
<h3 className="text-white font-bold text-2xl text-center px-4">
|
||||
{tablo.name}
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status Display */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Statut
|
||||
</label>
|
||||
<div className="text-gray-900 dark:text-white">
|
||||
{currentData.status === "todo" && "À faire"}
|
||||
{currentData.status === "in_progress" && "En cours"}
|
||||
{currentData.status === "done" && "Terminé"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Access Level */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Votre rôle
|
||||
</label>
|
||||
<div className="text-gray-900 dark:text-white">
|
||||
{tablo.is_admin ? "Administrateur" : "Invité"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Editable content */
|
||||
<>
|
||||
<ImageColorPicker
|
||||
creationMode={creationMode}
|
||||
setCreationMode={setCreationMode}
|
||||
selectedColor={selectedColor}
|
||||
setSelectedColor={setSelectedColor}
|
||||
/>
|
||||
|
||||
{/* Details */}
|
||||
<div className="space-y-4 mb-4">
|
||||
<div>
|
||||
<StatusPicker
|
||||
selectedStatus={currentData.status as StatusType}
|
||||
setSelectedStatus={(status) =>
|
||||
setEditData((prev) => (prev ? { ...prev, status } : null))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Invite User Section */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-3">
|
||||
Inviter un utilisateur
|
||||
</h3>
|
||||
<div className="flex space-x-2">
|
||||
<input
|
||||
type="email"
|
||||
value={inviteEmail}
|
||||
onChange={(e) => setInviteEmail(e.target.value)}
|
||||
placeholder="Email de l'utilisateur à inviter"
|
||||
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
/>
|
||||
{isInvitingUser ? (
|
||||
<div className="flex justify-center items-center">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSendInvite}
|
||||
disabled={!isEmailValid(inviteEmail)}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed rounded-md transition-colors"
|
||||
>
|
||||
Inviter
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Files Section */}
|
||||
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Fichiers</h3>
|
||||
{fileData?.fileNames && (
|
||||
<span className="bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 text-xs font-medium px-2 py-1 rounded-full">
|
||||
{fileData.fileNames.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowFiles(!showFiles)}
|
||||
className="flex items-center space-x-2 px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-white dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||
>
|
||||
<span>{showFiles ? "Masquer" : "Afficher"}</span>
|
||||
<svg
|
||||
className={`w-4 h-4 transition-transform ${showFiles ? "rotate-180" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showFiles && (
|
||||
<div className="space-y-4">
|
||||
{/* File Upload Section - Only for Admins */}
|
||||
{isAdmin && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 mb-4">
|
||||
<div className="flex items-center space-x-2 mb-3">
|
||||
<svg
|
||||
className="w-4 h-4 text-green-600 dark:text-green-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
|
||||
/>
|
||||
</svg>
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Ajouter un fichier
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
{!selectedFile ? (
|
||||
<div className="space-y-3">
|
||||
<FileTrigger allowsMultiple={false} onSelect={handleFileSelect}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-center py-8 border-2 border-dashed border-gray-300 dark:border-gray-600 hover:border-blue-400 dark:hover:border-blue-500 bg-gray-50 dark:bg-gray-800/50 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors"
|
||||
>
|
||||
<div className="flex flex-col items-center space-y-2">
|
||||
<svg
|
||||
className="w-8 h-8 text-gray-400 dark:text-gray-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
<div className="text-center">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Cliquez pour sélectionner un fichier
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</FileTrigger>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-3 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-md">
|
||||
<div className="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-white text-sm font-medium">
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{selectedFile.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{(selectedFile.size / 1024 / 1024).toFixed(2)} MB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFileUpload}
|
||||
disabled={isUploading}
|
||||
className="flex-1 px-4 py-2.5 text-sm font-medium text-white bg-green-600 hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed rounded-lg transition-colors flex items-center justify-center space-x-2 shadow-sm"
|
||||
>
|
||||
{isUploading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
<span>Ajout en cours...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
<span>Ajouter le fichier</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancelFileUpload}
|
||||
disabled={isUploading}
|
||||
className="px-4 py-2.5 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg transition-colors"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||
Taille maximale: 2MB
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File List */}
|
||||
<div>
|
||||
{filesLoading ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500"></div>
|
||||
<span className="ml-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
Chargement des fichiers...
|
||||
</span>
|
||||
</div>
|
||||
) : filesError ? (
|
||||
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<div className="flex items-center space-x-2">
|
||||
<svg
|
||||
className="w-5 h-5 text-red-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm text-red-700 dark:text-red-300">
|
||||
Erreur lors du chargement des fichiers
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : fileData && fileData.fileNames && fileData.fileNames.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{fileData.fileNames.map((fileName, index) => {
|
||||
const fileExtension = fileName.split(".").pop()?.toLowerCase() || "";
|
||||
const isImage = ["jpg", "jpeg", "png", "gif", "webp", "svg"].includes(
|
||||
fileExtension
|
||||
);
|
||||
const isPdf = fileExtension === "pdf";
|
||||
const isText = ["txt", "md", "json", "csv"].includes(fileExtension);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center space-x-3 p-3 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:shadow-sm transition-shadow group"
|
||||
>
|
||||
<button
|
||||
onClick={() => handleDownloadFile(fileName)}
|
||||
disabled={downloadingFile === fileName}
|
||||
className={`w-10 h-10 rounded-lg flex items-center justify-center text-white text-sm font-medium transition-all hover:scale-105 ${
|
||||
isImage
|
||||
? "bg-purple-500 hover:bg-purple-600"
|
||||
: isPdf
|
||||
? "bg-red-500 hover:bg-red-600"
|
||||
: isText
|
||||
? "bg-blue-500 hover:bg-blue-600"
|
||||
: "bg-gray-500 hover:bg-gray-600"
|
||||
} ${
|
||||
downloadingFile === fileName
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: "cursor-pointer"
|
||||
}`}
|
||||
title={`Télécharger ${fileName}`}
|
||||
>
|
||||
{downloadingFile === fileName ? (
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
|
||||
) : isImage ? (
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
) : isPdf ? (
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p
|
||||
className="text-sm font-medium text-gray-900 dark:text-white truncate"
|
||||
title={fileName}
|
||||
>
|
||||
{fileName}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 uppercase">
|
||||
{fileExtension || "Fichier"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDownloadFile(fileName)}
|
||||
disabled={downloadingFile === fileName}
|
||||
className="p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:text-blue-400 dark:hover:bg-blue-900/20 transition-colors"
|
||||
aria-label={`Télécharger ${fileName}`}
|
||||
>
|
||||
{downloadingFile === fileName ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-500"></div>
|
||||
) : (
|
||||
<DownloadIcon className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDeleteFile(fileName)}
|
||||
disabled={deletingFile === fileName}
|
||||
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:text-red-400 dark:hover:bg-red-900/20 transition-colors"
|
||||
aria-label={`Supprimer ${fileName}`}
|
||||
>
|
||||
{deletingFile === fileName ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-red-500"></div>
|
||||
) : (
|
||||
<Trash2Icon className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<svg
|
||||
className="w-12 h-12 text-gray-300 dark:text-gray-600 mx-auto mb-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 5a2 2 0 012-2h2a2 2 0 012 2v0H8v0z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Aucun fichier dans ce tablo
|
||||
</p>
|
||||
{isAdmin && (
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
Ajoutez votre premier fichier ci-dessus
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Members Section */}
|
||||
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Membres</h3>
|
||||
{members && (
|
||||
<span className="bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 text-xs font-medium px-2 py-1 rounded-full">
|
||||
{members.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowMembers(!showMembers)}
|
||||
className="flex items-center space-x-2 px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-white dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||
>
|
||||
<span>{showMembers ? "Masquer" : "Afficher"}</span>
|
||||
<svg
|
||||
className={`w-4 h-4 transition-transform ${showMembers ? "rotate-180" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showMembers && (
|
||||
<div className="space-y-2">
|
||||
{members && members.length > 0 ? (
|
||||
members.map((member, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center space-x-3 p-2 bg-gray-50 dark:bg-gray-800 rounded-md"
|
||||
>
|
||||
<div className="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-white text-sm font-medium">
|
||||
{member.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span className="text-sm text-gray-900 dark:text-white">{member.name}</span>
|
||||
{member.is_admin ? (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{member.id === currentUser?.id ? "(Vous, Admin)" : "(Admin)"}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{member.id === currentUser?.id ? "(Vous, Invité)" : "(Invité)"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Aucun membre trouvé</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-gray-200 dark:border-gray-700 shrink-0 bg-gray-50 dark:bg-gray-900/50">
|
||||
<div className="flex space-x-3 ml-auto">
|
||||
{!isAdmin ? (
|
||||
<button
|
||||
type="button"
|
||||
className="px-6 py-2.5 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600 rounded-lg transition-colors"
|
||||
onClick={onClose}
|
||||
>
|
||||
Fermer
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="px-6 py-2.5 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600 rounded-lg transition-colors"
|
||||
onClick={onClose}
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="px-6 py-2.5 text-sm font-medium text-white bg-green-600 hover:bg-green-700 rounded-lg shadow-sm transition-colors flex items-center space-x-2"
|
||||
onClick={handleSaveEdit}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
<span>Sauvegarder</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ClickOutside>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
257
apps/main/src/components/TabloSettingsSection.tsx
Normal file
257
apps/main/src/components/TabloSettingsSection.tsx
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
import { TabloUpdate, UserTablo } from "@xtablo/shared/types/tablos.types";
|
||||
import { Button } from "@xtablo/ui/components/button";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useInviteUser } from "../hooks/invite";
|
||||
import { useTabloMembers } from "../hooks/tablos";
|
||||
import { useUser } from "../providers/UserStoreProvider";
|
||||
import { ClickOutside } from "./ClickOutside";
|
||||
import { ImageColorPicker } from "./ImageColorPicker";
|
||||
import { StatusPicker } from "./StatusPicker";
|
||||
|
||||
type StatusType = "todo" | "in_progress" | "done";
|
||||
|
||||
interface TabloSettingsSectionProps {
|
||||
tablo: UserTablo;
|
||||
isAdmin: boolean;
|
||||
onEdit: (updatedTablo: TabloUpdate & { id: string }) => void;
|
||||
}
|
||||
|
||||
export const TabloSettingsSection = ({ tablo, isAdmin, onEdit }: TabloSettingsSectionProps) => {
|
||||
const currentUser = useUser();
|
||||
const [editData, setEditData] = useState<UserTablo | null>(tablo);
|
||||
const [isEditingName, setIsEditingName] = useState(false);
|
||||
const [creationMode, setCreationMode] = useState<"image" | "color">("color");
|
||||
const [selectedColor, setSelectedColor] = useState(tablo.color || "bg-blue-500");
|
||||
const { data: members } = useTabloMembers(tablo.id);
|
||||
|
||||
const [inviteEmail, setInviteEmail] = useState("");
|
||||
const { mutate: inviteUser, isPending: isInvitingUser } = useInviteUser();
|
||||
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setEditData(tablo);
|
||||
setSelectedColor(tablo.color || "bg-blue-500");
|
||||
}, [tablo]);
|
||||
|
||||
// Auto-focus name input when editing
|
||||
useEffect(() => {
|
||||
if (isEditingName && nameInputRef.current) {
|
||||
nameInputRef.current.focus();
|
||||
nameInputRef.current.select();
|
||||
}
|
||||
}, [isEditingName]);
|
||||
|
||||
const handleSaveEdit = () => {
|
||||
if (editData && onEdit) {
|
||||
const updatedTablo: TabloUpdate & { id: string } = {
|
||||
id: editData.id,
|
||||
name: editData.name,
|
||||
color: creationMode === "color" ? selectedColor : null,
|
||||
status: editData.status,
|
||||
};
|
||||
onEdit(updatedTablo);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendInvite = () => {
|
||||
if (inviteEmail.trim()) {
|
||||
inviteUser({ email: inviteEmail, tablo_id: tablo.id });
|
||||
setInviteEmail("");
|
||||
}
|
||||
};
|
||||
|
||||
const isEmailValid = (email: string): boolean => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
};
|
||||
|
||||
const currentData = editData || tablo;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Paramètres</h1>
|
||||
<p className="text-muted-foreground mt-1">Configurez votre tablo et gérez les accès</p>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<Button onClick={handleSaveEdit} className="px-6">
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
Sauvegarder les modifications
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isAdmin ? (
|
||||
/* Read-only view for non-admins */
|
||||
<div className="space-y-6">
|
||||
{/* Tablo Preview */}
|
||||
<div className="bg-card rounded-lg border border-border p-6">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-4">Aperçu</h3>
|
||||
<div className="relative h-32 rounded-lg overflow-hidden">
|
||||
{tablo.image ? (
|
||||
<img src={tablo.image} alt={tablo.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div
|
||||
className={`w-full h-full ${
|
||||
tablo.color || "bg-gray-400"
|
||||
} flex items-center justify-center`}
|
||||
>
|
||||
<h3 className="text-white font-bold text-2xl text-center px-4">{tablo.name}</h3>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status and Role */}
|
||||
<div className="bg-card rounded-lg border border-border p-6">
|
||||
<div className="flex gap-8">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-4">Statut</h3>
|
||||
<div className="text-foreground">
|
||||
{currentData.status === "todo" && "À faire"}
|
||||
{currentData.status === "in_progress" && "En cours"}
|
||||
{currentData.status === "done" && "Terminé"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-4">Votre rôle</h3>
|
||||
<div className="text-foreground">
|
||||
{tablo.is_admin ? "Administrateur" : "Invité"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Editable view for admins */
|
||||
<div className="space-y-6">
|
||||
{/* Name Edit */}
|
||||
<div className="bg-card rounded-lg border border-border p-6">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-4">Nom du tablo</h3>
|
||||
{isEditingName ? (
|
||||
<ClickOutside onClickOutside={() => setIsEditingName(false)}>
|
||||
<input
|
||||
ref={nameInputRef}
|
||||
type="text"
|
||||
value={editData?.name}
|
||||
onChange={(e) =>
|
||||
setEditData((prev) => (prev ? { ...prev, name: e.target.value } : null))
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
setIsEditingName(false);
|
||||
}
|
||||
}}
|
||||
className="w-full px-3 py-2 text-lg font-medium text-foreground bg-transparent border-b-2 border-primary focus:outline-none focus:border-primary"
|
||||
placeholder="Nom du tablo"
|
||||
/>
|
||||
</ClickOutside>
|
||||
) : (
|
||||
<div
|
||||
className="text-lg font-medium text-foreground cursor-text hover:text-primary transition-colors"
|
||||
onClick={() => setIsEditingName(true)}
|
||||
>
|
||||
{editData?.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Color/Image Picker */}
|
||||
<div className="bg-card rounded-lg border border-border p-6">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-4">Apparence</h3>
|
||||
<ImageColorPicker
|
||||
creationMode={creationMode}
|
||||
setCreationMode={setCreationMode}
|
||||
selectedColor={selectedColor}
|
||||
setSelectedColor={setSelectedColor}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status Picker */}
|
||||
<div className="bg-card rounded-lg border border-border p-6">
|
||||
<StatusPicker
|
||||
selectedStatus={currentData.status as StatusType}
|
||||
setSelectedStatus={(status) =>
|
||||
setEditData((prev) => (prev ? { ...prev, status } : null))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Invite User Section */}
|
||||
<div className="bg-card rounded-lg border border-border p-6">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-4">Inviter un utilisateur</h3>
|
||||
<div className="flex space-x-2">
|
||||
<input
|
||||
type="email"
|
||||
value={inviteEmail}
|
||||
onChange={(e) => setInviteEmail(e.target.value)}
|
||||
placeholder="Email de l'utilisateur à inviter"
|
||||
className="flex-1 px-3 py-2 border border-input rounded-md shadow-sm placeholder-muted-foreground focus:outline-none focus:ring-primary focus:border-primary bg-background text-foreground"
|
||||
/>
|
||||
{isInvitingUser ? (
|
||||
<div className="flex justify-center items-center">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSendInvite}
|
||||
disabled={!isEmailValid(inviteEmail)}
|
||||
>
|
||||
Inviter
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Members List */}
|
||||
<div className="bg-card rounded-lg border border-border p-6">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-4">
|
||||
Membres
|
||||
{members && (
|
||||
<span className="ml-2 text-sm font-normal text-muted-foreground">
|
||||
({members.length})
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
{members && members.length > 0 ? (
|
||||
members.map((member, index) => (
|
||||
<div key={index} className="flex items-center space-x-3 p-3 bg-muted rounded-lg">
|
||||
<div className="w-10 h-10 bg-primary rounded-full flex items-center justify-center text-primary-foreground text-sm font-medium">
|
||||
{member.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<span className="text-sm font-medium text-foreground">{member.name}</span>
|
||||
{member.is_admin ? (
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
{member.id === currentUser?.id ? "(Vous, Admin)" : "(Admin)"}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
{member.id === currentUser?.id ? "(Vous, Invité)" : "(Invité)"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Aucun membre trouvé</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -31,7 +31,7 @@ export const useInviteUser = () => {
|
|||
|
||||
export const useJoinTablo = () => {
|
||||
const api = useAuthedApi();
|
||||
const { mutate } = useMutation({
|
||||
const { mutate } = useMutation<{ tablo_id: string }, unknown, { token: string }>({
|
||||
mutationFn: async ({ token }: { token: string }) => {
|
||||
const { data } = await api.post("/api/v1/tablos/join", { token });
|
||||
return data;
|
||||
|
|
|
|||
|
|
@ -4,10 +4,9 @@ import { EventModal } from "../components/EventModal";
|
|||
import { Layout } from "../components/Layout";
|
||||
import { ProtectedRoute } from "../components/ProtectedRoute";
|
||||
import { AvailabilitiesPage } from "../pages/availabilities";
|
||||
import { BookingsPage } from "../pages/bookings";
|
||||
import { ChantiersPage } from "../pages/chantiers";
|
||||
import { ChatPage } from "../pages/chat";
|
||||
import { EventTypesPage } from "../pages/event-types-page";
|
||||
import { EventsPage } from "../pages/events";
|
||||
import { FeedbackPage } from "../pages/feedback";
|
||||
import { JoinPage } from "../pages/join";
|
||||
import { LandingPage } from "../pages/landing";
|
||||
|
|
@ -20,6 +19,7 @@ import { ResetPasswordPage } from "../pages/reset-password";
|
|||
import SettingsPage from "../pages/settings";
|
||||
import { SignUpPage } from "../pages/signup";
|
||||
import { TabloPage } from "../pages/tablo";
|
||||
import { TabloDetailsPage } from "../pages/tablo-details";
|
||||
import ChatProvider from "../providers/ChatProvider";
|
||||
|
||||
export const routes: RouteObject[] = [
|
||||
|
|
@ -28,6 +28,10 @@ export const routes: RouteObject[] = [
|
|||
path: "/",
|
||||
element: <ProtectedRoute fallback="/login" />,
|
||||
children: [
|
||||
{
|
||||
path: "tablos/:tabloId",
|
||||
element: <TabloDetailsPage />,
|
||||
},
|
||||
{
|
||||
path: "",
|
||||
element: <Layout />,
|
||||
|
|
@ -75,13 +79,8 @@ export const routes: RouteObject[] = [
|
|||
element: <AvailabilitiesPage />,
|
||||
},
|
||||
{
|
||||
path: "bookings",
|
||||
element: <BookingsPage />,
|
||||
children: [{ index: true }, { path: ":tablo_id" }],
|
||||
},
|
||||
{
|
||||
path: "event-types",
|
||||
element: <EventTypesPage />,
|
||||
path: "events",
|
||||
element: <EventsPage />,
|
||||
},
|
||||
{
|
||||
path: "feedback",
|
||||
|
|
|
|||
|
|
@ -380,7 +380,7 @@ export function PublicBookingPage() {
|
|||
<div className="lg:col-span-1">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6 sticky top-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-3">
|
||||
{eventType?.name || "Type d'événement"}
|
||||
{eventType?.name || "Type d'appel"}
|
||||
</h2>
|
||||
|
||||
{eventType?.description && (
|
||||
|
|
|
|||
|
|
@ -96,6 +96,17 @@ export function AvailabilitiesPage() {
|
|||
});
|
||||
};
|
||||
|
||||
// Filter exceptions to only show upcoming dates (today and future)
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const upcomingExceptions = exceptions
|
||||
.map((exception, originalIndex) => ({ exception, originalIndex }))
|
||||
.filter(({ exception }) => {
|
||||
const exceptionDate = new Date(exception.date);
|
||||
exceptionDate.setHours(0, 0, 0, 0);
|
||||
return exceptionDate >= today;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<header className="bg-card shadow-sm border-b border-border">
|
||||
|
|
@ -253,14 +264,14 @@ export function AvailabilitiesPage() {
|
|||
Gérez vos exceptions de disponibilité pour des dates spécifiques
|
||||
</Text>
|
||||
</div>
|
||||
{exceptions.length > 0 && (
|
||||
{upcomingExceptions.length > 0 && (
|
||||
<Button variant="default" size="lg" onClick={() => setExceptionModalOpen(true)}>
|
||||
<PlusIcon /> Ajouter une exception
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{exceptions.length === 0 ? (
|
||||
{upcomingExceptions.length === 0 ? (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>Aucune exception définie</EmptyTitle>
|
||||
|
|
@ -277,9 +288,9 @@ export function AvailabilitiesPage() {
|
|||
</Empty>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{exceptions.map((exception, index) => (
|
||||
{upcomingExceptions.map(({ exception, originalIndex }) => (
|
||||
<div
|
||||
key={`${exception.date}-${index}`}
|
||||
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"
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
|
|
@ -328,7 +339,7 @@ export function AvailabilitiesPage() {
|
|||
size="sm"
|
||||
onClick={() => {
|
||||
deleteException(
|
||||
{ exceptionIndex: index },
|
||||
{ exceptionIndex: originalIndex },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.add({
|
||||
|
|
|
|||
|
|
@ -1,488 +0,0 @@
|
|||
import { EventDetailsModal } from "@ui/components/EventDetailsModal";
|
||||
import { LoadingSpinner } from "@ui/components/LoadingSpinner";
|
||||
import { getTextColorFromTabloColor } from "@xtablo/shared";
|
||||
import { EventAndTablo } from "@xtablo/shared/types/events.types";
|
||||
import { Button } from "@xtablo/ui/components/button";
|
||||
import { ButtonGroup } from "@xtablo/ui/components/button-group";
|
||||
import { Input } from "@xtablo/ui/components/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@xtablo/ui/components/select";
|
||||
import { Strong, Text, TypographyH3, TypographyMuted } from "@xtablo/ui/components/typography";
|
||||
import { Calendar as CalendarIcon, ChevronLeft, ChevronRight, SearchIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useEventsByTablo } from "../hooks/events";
|
||||
import { useGetAllTabloAccess, useTablosList } from "../hooks/tablos";
|
||||
|
||||
type BookingStatus = "all" | "upcoming" | "past";
|
||||
|
||||
interface BookingStatusOption {
|
||||
id: BookingStatus;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const statusOptions: BookingStatusOption[] = [
|
||||
{ id: "upcoming", name: "À venir" },
|
||||
{ id: "past", name: "Passés" },
|
||||
];
|
||||
|
||||
export const BookingsPage = () => {
|
||||
const { tablo_id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [selectedTabloId, setSelectedTabloId] = useState<string>(tablo_id || "all");
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<BookingStatus>("upcoming");
|
||||
const [selectedEvent, setSelectedEvent] = useState<EventAndTablo | null>(null);
|
||||
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||
|
||||
// Fetch tablos and events
|
||||
const { data: tablos, isLoading: tablosLoading } = useTablosList();
|
||||
const { data: events = [], isLoading: eventsLoading } = useEventsByTablo(
|
||||
selectedTabloId !== "all" ? selectedTabloId : null
|
||||
);
|
||||
// Fetch all tablo accesses for permissions
|
||||
const { data: tabloAccess } = useGetAllTabloAccess();
|
||||
|
||||
// Filter and search events
|
||||
const filteredEvents = useMemo(() => {
|
||||
if (!events) return [];
|
||||
|
||||
let filtered = events;
|
||||
|
||||
// Search filter
|
||||
if (searchTerm) {
|
||||
filtered = filtered.filter(
|
||||
(event) =>
|
||||
event.title?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
event.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
event.tablo_name?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
// Status filter
|
||||
if (statusFilter !== "all") {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
|
||||
filtered = filtered.filter((event) => {
|
||||
if (!event.start_date) return false;
|
||||
|
||||
const eventDate = new Date(event.start_date);
|
||||
eventDate.setHours(0, 0, 0, 0);
|
||||
|
||||
switch (statusFilter) {
|
||||
case "upcoming":
|
||||
return eventDate >= today;
|
||||
case "past":
|
||||
return eventDate < today;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return filtered.sort((a, b) => {
|
||||
if (!a.start_date || !b.start_date) return 0;
|
||||
return new Date(a.start_date).getTime() - new Date(b.start_date).getTime();
|
||||
});
|
||||
}, [events, searchTerm, statusFilter]);
|
||||
|
||||
// Pagination logic
|
||||
const totalItems = filteredEvents.length;
|
||||
const totalPages = Math.ceil(totalItems / itemsPerPage);
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
const endIndex = startIndex + itemsPerPage;
|
||||
const paginatedEvents = filteredEvents.slice(startIndex, endIndex);
|
||||
|
||||
// Reset to first page when filters or page size change
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [searchTerm, statusFilter, selectedTabloId, itemsPerPage]);
|
||||
|
||||
const formatEventDateTime = (event: EventAndTablo) => {
|
||||
if (!event.start_date) return "Date non définie";
|
||||
|
||||
try {
|
||||
const date = new Date(event.start_date);
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
};
|
||||
let formatted = date.toLocaleDateString("fr-FR", options);
|
||||
|
||||
if (event.start_time) {
|
||||
// Remove seconds from time (HH:MM:SS -> HH:MM)
|
||||
const startTime = event.start_time.substring(0, 5);
|
||||
formatted += ` à ${startTime}`;
|
||||
if (event.end_time) {
|
||||
const endTime = event.end_time.substring(0, 5);
|
||||
formatted += ` - ${endTime}`;
|
||||
}
|
||||
}
|
||||
|
||||
return formatted;
|
||||
} catch {
|
||||
return "Date invalide";
|
||||
}
|
||||
};
|
||||
|
||||
const getEventStatusBadge = (event: EventAndTablo) => {
|
||||
if (!event.start_date) return null;
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const eventDate = new Date(event.start_date);
|
||||
eventDate.setHours(0, 0, 0, 0);
|
||||
|
||||
if (eventDate.getTime() === today.getTime()) {
|
||||
return (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary/10 text-primary">
|
||||
Aujourd'hui
|
||||
</span>
|
||||
);
|
||||
} else if (eventDate > today) {
|
||||
return (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-secondary text-secondary-foreground">
|
||||
À venir
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-muted text-muted-foreground">
|
||||
Passé
|
||||
</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateEvent = () => {
|
||||
const today = new Date();
|
||||
const dateParam = today.toISOString();
|
||||
const tabloParam = selectedTabloId !== "all" ? `&tablo_id=${selectedTabloId}` : "";
|
||||
navigate(`/planning/create?date=${dateParam}${tabloParam}`);
|
||||
};
|
||||
|
||||
// Check if an event can be edited (admin access required)
|
||||
const canEditEvent = (event: EventAndTablo) => {
|
||||
return tabloAccess?.find((access) => access.tablo_id === event.tablo_id && access.is_admin)
|
||||
? true
|
||||
: false;
|
||||
};
|
||||
|
||||
const handleEditEvent = (event: EventAndTablo) => {
|
||||
if (event.event_id && event.tablo_id && canEditEvent(event)) {
|
||||
navigate(`/planning/${event.tablo_id}/events/${event.event_id}/edit`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewEvent = (event: EventAndTablo) => {
|
||||
setSelectedEvent(event);
|
||||
setIsDetailsModalOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{/* Header */}
|
||||
<header className="bg-card shadow-sm border-b border-border">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<TypographyH3>Réservations</TypographyH3>
|
||||
<TypographyMuted>Gérez vos événements et réservations</TypographyMuted>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Button onClick={handleCreateEvent}>
|
||||
<CalendarIcon className="w-4 h-4 mr-2" />
|
||||
Nouvel événement
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
{/* Filters */}
|
||||
<div className="bg-card rounded-lg shadow-sm border border-border p-6 mb-6">
|
||||
<div className="flex flex-col lg:flex-row gap-4 items-start lg:items-center">
|
||||
{/* Search */}
|
||||
<div className="flex-1 w-full">
|
||||
<div className="relative">
|
||||
<SearchIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Rechercher un événement..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10 h-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tablo Filter */}
|
||||
<div className="w-full lg:w-64">
|
||||
<Select value={selectedTabloId} onValueChange={(value) => setSelectedTabloId(value)}>
|
||||
<SelectTrigger className="w-full h-10" aria-label="Filtrer par tableau">
|
||||
<SelectValue placeholder="Tous les tableaux" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Tous les tableaux</SelectItem>
|
||||
{tablos?.map((tablo) => (
|
||||
<SelectItem key={tablo.id} value={tablo.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={twMerge(
|
||||
"w-2 h-2 rounded-full",
|
||||
tablo.color || "bg-muted-foreground"
|
||||
)}
|
||||
/>
|
||||
{tablo.name}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Status Filter */}
|
||||
<ButtonGroup orientation="horizontal">
|
||||
{statusOptions.map((option) => (
|
||||
<Button
|
||||
key={option.id}
|
||||
variant={statusFilter === option.id ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setStatusFilter(option.id as BookingStatus)}
|
||||
className="rounded-full"
|
||||
>
|
||||
{option.name}
|
||||
</Button>
|
||||
))}
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Events List */}
|
||||
<div className="bg-card rounded-lg shadow-sm border border-border">
|
||||
{tablosLoading || eventsLoading ? (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
) : paginatedEvents.length === 0 ? (
|
||||
<div className="p-12 text-center">
|
||||
<CalendarIcon className="mx-auto h-12 w-12 text-muted-foreground" />
|
||||
<h3 className="mt-2 text-sm font-medium text-foreground">Aucun événement trouvé</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{searchTerm || statusFilter !== "all"
|
||||
? "Essayez de modifier vos filtres de recherche."
|
||||
: "Commencez par créer votre premier événement."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{paginatedEvents.map((event) => (
|
||||
<div
|
||||
key={event.event_id}
|
||||
className="p-6 hover:bg-muted transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
<Strong className="text-lg text-foreground truncate">
|
||||
{event.title || "Événement sans titre"}
|
||||
</Strong>
|
||||
{getEventStatusBadge(event)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4 text-sm text-muted-foreground mb-2">
|
||||
<span className="flex items-center">
|
||||
<CalendarIcon className="w-4 h-4 mr-1" />
|
||||
{formatEventDateTime(event)}
|
||||
</span>
|
||||
{event.tablo_name && (
|
||||
<span
|
||||
className={twMerge(
|
||||
"inline-flex items-center px-2 py-1 rounded-md text-xs font-medium",
|
||||
event.tablo_color,
|
||||
getTextColorFromTabloColor(event.tablo_color)
|
||||
)}
|
||||
>
|
||||
{event.tablo_name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{event.description && (
|
||||
<Text className="text-muted-foreground line-clamp-2">
|
||||
{event.description}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 ml-4">
|
||||
<Button variant="outline" size="sm" onClick={() => handleViewEvent(event)}>
|
||||
Détails
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{totalItems > 0 && (
|
||||
<div className="bg-card rounded-lg shadow-sm border border-border mt-4 px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4 text-sm text-muted-foreground">
|
||||
<span>
|
||||
Affichage de {startIndex + 1} à {Math.min(endIndex, totalItems)} sur {totalItems}{" "}
|
||||
événements
|
||||
</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="whitespace-nowrap">Éléments par page:</span>
|
||||
<Select
|
||||
value={itemsPerPage.toString()}
|
||||
onValueChange={(value) => setItemsPerPage(Number(value))}
|
||||
>
|
||||
<SelectTrigger className="min-w-16 h-8" aria-label="Nombre d'éléments par page">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="5">5</SelectItem>
|
||||
<SelectItem value="10">10</SelectItem>
|
||||
<SelectItem value="20">20</SelectItem>
|
||||
<SelectItem value="50">50</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Précédent
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center space-x-1">
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||
.filter((page) => {
|
||||
// Show first page, last page, current page, and pages around current
|
||||
return (
|
||||
page === 1 || page === totalPages || Math.abs(page - currentPage) <= 1
|
||||
);
|
||||
})
|
||||
.map((page, index, array) => {
|
||||
// Add ellipsis if there's a gap
|
||||
const prevPage = array[index - 1];
|
||||
const showEllipsis = prevPage && page - prevPage > 1;
|
||||
|
||||
return (
|
||||
<div key={page} className="flex items-center">
|
||||
{showEllipsis && (
|
||||
<span className="px-2 text-muted-foreground">...</span>
|
||||
)}
|
||||
<Button
|
||||
variant={currentPage === page ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(page)}
|
||||
className={
|
||||
currentPage === page
|
||||
? "bg-emerald-700 text-white hover:bg-emerald-600"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((prev) => Math.min(prev + 1, totalPages))}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
Suivant
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats Summary */}
|
||||
{filteredEvents.length > 0 && (
|
||||
<div className="mt-6 bg-card rounded-lg shadow-sm border border-border p-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-foreground">{filteredEvents.length}</div>
|
||||
<div className="text-sm text-muted-foreground">Événements trouvés</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-foreground">
|
||||
{
|
||||
filteredEvents.filter((e) => {
|
||||
if (!e.start_date) return false;
|
||||
const eventDate = new Date(e.start_date);
|
||||
return eventDate >= new Date();
|
||||
}).length
|
||||
}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">À venir</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-primary">
|
||||
{
|
||||
filteredEvents.filter((e) => {
|
||||
if (!e.start_date) return false;
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const eventDate = new Date(e.start_date);
|
||||
eventDate.setHours(0, 0, 0, 0);
|
||||
return eventDate.getTime() === today.getTime();
|
||||
}).length
|
||||
}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">Aujourd'hui</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Event Details Modal */}
|
||||
<EventDetailsModal
|
||||
event={selectedEvent}
|
||||
isOpen={isDetailsModalOpen}
|
||||
onClose={() => {
|
||||
setIsDetailsModalOpen(false);
|
||||
setSelectedEvent(null);
|
||||
}}
|
||||
onEdit={() => selectedEvent && handleEditEvent(selectedEvent)}
|
||||
canEdit={selectedEvent ? canEditEvent(selectedEvent) : false}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,119 +0,0 @@
|
|||
import { EventTypeModal } from "@ui/components/EventTypeModal";
|
||||
import { toast } from "@xtablo/shared";
|
||||
import { Button } from "@xtablo/ui/components/button";
|
||||
import { Text, TypographyH3, TypographyMuted } from "@xtablo/ui/components/typography";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { EventTypeCard } from "../components/EventTypeCard";
|
||||
import { EventTypeConfig, useEventTypes } from "../hooks/event-types";
|
||||
|
||||
export function EventTypesPage() {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingEventType, setEditingEventType] = useState<
|
||||
(EventTypeConfig & { id: string }) | null
|
||||
>(null);
|
||||
const [formData, setFormData] = useState<Partial<EventTypeConfig> & { isActive?: boolean }>({
|
||||
name: "",
|
||||
description: "",
|
||||
duration: 60,
|
||||
bufferTime: 15,
|
||||
maxBookingsPerDay: 8,
|
||||
requiresApproval: false,
|
||||
});
|
||||
const { eventTypes: eventTypesData, addEventType, updateEventType } = useEventTypes();
|
||||
|
||||
const handleCreateEventType = () => {
|
||||
setEditingEventType(null);
|
||||
setFormData({
|
||||
name: "",
|
||||
description: "",
|
||||
duration: 60,
|
||||
isActive: true,
|
||||
bufferTime: 15,
|
||||
maxBookingsPerDay: 8,
|
||||
requiresApproval: false,
|
||||
});
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEditEventType = (id: string, eventType: EventTypeConfig) => {
|
||||
setEditingEventType({ id, ...eventType });
|
||||
setFormData(eventType as EventTypeConfig);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveEventType = () => {
|
||||
if (!formData.name) {
|
||||
toast.add({
|
||||
title: "Erreur",
|
||||
description: "Veuillez remplir tous les champs obligatoires",
|
||||
type: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (editingEventType) {
|
||||
updateEventType({
|
||||
id: editingEventType.id,
|
||||
eventType: formData as EventTypeConfig,
|
||||
});
|
||||
} else {
|
||||
addEventType({ eventType: formData as EventTypeConfig });
|
||||
}
|
||||
|
||||
setIsModalOpen(false);
|
||||
setEditingEventType(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<header className="bg-card shadow-sm border-b border-border">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<TypographyH3>Types d'événements</TypographyH3>
|
||||
<TypographyMuted>
|
||||
Configurez les différents types d'événements que vous proposez
|
||||
</TypographyMuted>
|
||||
</div>
|
||||
<Button size="lg" variant="default" onClick={handleCreateEventType}>
|
||||
<PlusIcon className="w-4 h-4 mr-2" /> Nouveau type
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-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">
|
||||
<Text className="text-muted-foreground mb-4">
|
||||
Aucun type d'événement configuré
|
||||
</Text>
|
||||
<Button variant="default" size="lg" onClick={handleCreateEventType}>
|
||||
<PlusIcon className="w-4 h-4 mr-2" /> Créer votre premier type
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<EventTypeModal
|
||||
isModalOpen={isModalOpen}
|
||||
setIsModalOpen={setIsModalOpen}
|
||||
editingEventType={editingEventType}
|
||||
formData={formData as EventTypeConfig}
|
||||
setFormData={setFormData}
|
||||
handleSaveEventType={handleSaveEventType}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
619
apps/main/src/pages/events.tsx
Normal file
619
apps/main/src/pages/events.tsx
Normal file
|
|
@ -0,0 +1,619 @@
|
|||
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";
|
||||
import { Button } from "@xtablo/ui/components/button";
|
||||
import { ButtonGroup } from "@xtablo/ui/components/button-group";
|
||||
import { Input } from "@xtablo/ui/components/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
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 { 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";
|
||||
|
||||
type BookingStatus = "all" | "upcoming" | "past";
|
||||
|
||||
interface BookingStatusOption {
|
||||
id: BookingStatus;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const statusOptions: BookingStatusOption[] = [
|
||||
{ id: "upcoming", name: "À venir" },
|
||||
{ id: "past", name: "Passés" },
|
||||
];
|
||||
|
||||
export function EventsPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Events/Bookings state
|
||||
const [selectedTabloId, setSelectedTabloId] = useState<string>("all");
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<BookingStatus>("upcoming");
|
||||
const [selectedEvent, setSelectedEvent] = useState<EventAndTablo | null>(null);
|
||||
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
|
||||
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(() => {
|
||||
if (!events) return [];
|
||||
|
||||
let filtered = events;
|
||||
|
||||
// Search filter
|
||||
if (searchTerm) {
|
||||
filtered = filtered.filter(
|
||||
(event) =>
|
||||
event.title?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
event.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
event.tablo_name?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
// Status filter
|
||||
if (statusFilter !== "all") {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
filtered = filtered.filter((event) => {
|
||||
if (!event.start_date) return false;
|
||||
|
||||
const eventDate = new Date(event.start_date);
|
||||
eventDate.setHours(0, 0, 0, 0);
|
||||
|
||||
switch (statusFilter) {
|
||||
case "upcoming":
|
||||
return eventDate >= today;
|
||||
case "past":
|
||||
return eventDate < today;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return filtered.sort((a, b) => {
|
||||
if (!a.start_date || !b.start_date) return 0;
|
||||
return new Date(a.start_date).getTime() - new Date(b.start_date).getTime();
|
||||
});
|
||||
}, [events, searchTerm, statusFilter]);
|
||||
|
||||
// Pagination logic
|
||||
const totalItems = filteredEvents.length;
|
||||
const totalPages = Math.ceil(totalItems / itemsPerPage);
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
const endIndex = startIndex + itemsPerPage;
|
||||
const paginatedEvents = filteredEvents.slice(startIndex, endIndex);
|
||||
|
||||
// Reset to first page when filters or page size change
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [searchTerm, statusFilter, selectedTabloId, itemsPerPage]);
|
||||
|
||||
// Event Types handlers
|
||||
const handleCreateEventType = () => {
|
||||
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";
|
||||
|
||||
try {
|
||||
const date = new Date(event.start_date);
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
};
|
||||
let formatted = date.toLocaleDateString("fr-FR", options);
|
||||
|
||||
if (event.start_time) {
|
||||
const startTime = event.start_time.substring(0, 5);
|
||||
formatted += ` à ${startTime}`;
|
||||
if (event.end_time) {
|
||||
const endTime = event.end_time.substring(0, 5);
|
||||
formatted += ` - ${endTime}`;
|
||||
}
|
||||
}
|
||||
|
||||
return formatted;
|
||||
} catch {
|
||||
return "Date invalide";
|
||||
}
|
||||
};
|
||||
|
||||
const getEventStatusBadge = (event: EventAndTablo) => {
|
||||
if (!event.start_date) return null;
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const eventDate = new Date(event.start_date);
|
||||
eventDate.setHours(0, 0, 0, 0);
|
||||
|
||||
if (eventDate.getTime() === today.getTime()) {
|
||||
return (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary/10 text-primary">
|
||||
Aujourd'hui
|
||||
</span>
|
||||
);
|
||||
} else if (eventDate > today) {
|
||||
return (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-secondary text-secondary-foreground">
|
||||
À venir
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-muted text-muted-foreground">
|
||||
Passé
|
||||
</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateEvent = () => {
|
||||
const today = new Date();
|
||||
const dateParam = today.toISOString();
|
||||
const tabloParam = selectedTabloId !== "all" ? `&tablo_id=${selectedTabloId}` : "";
|
||||
navigate(`/planning/create?date=${dateParam}${tabloParam}`);
|
||||
};
|
||||
|
||||
const canEditEvent = (event: EventAndTablo) => {
|
||||
return tabloAccess?.find((access) => access.tablo_id === event.tablo_id && access.is_admin)
|
||||
? true
|
||||
: false;
|
||||
};
|
||||
|
||||
const handleEditEvent = (event: EventAndTablo) => {
|
||||
if (event.event_id && event.tablo_id && canEditEvent(event)) {
|
||||
navigate(`/planning/${event.tablo_id}/events/${event.event_id}/edit`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewEvent = (event: EventAndTablo) => {
|
||||
setSelectedEvent(event);
|
||||
setIsDetailsModalOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{/* Header */}
|
||||
<header className="bg-card shadow-sm border-b border-border">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div>
|
||||
<TypographyH3>Mes Événements</TypographyH3>
|
||||
<TypographyMuted>
|
||||
Gérez vos événements, réservations et types d'appels
|
||||
</TypographyMuted>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content with Tabs */}
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<Tabs defaultValue="events" className="w-full">
|
||||
<TabsList className="mb-6">
|
||||
<TabsTrigger value="events">Événements</TabsTrigger>
|
||||
<TabsTrigger value="event-types">Types d'appels</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Events Tab */}
|
||||
<TabsContent value="events" className="space-y-6">
|
||||
<div className="flex items-center justify-end mb-4">
|
||||
<Button onClick={handleCreateEvent}>
|
||||
<CalendarIcon className="w-4 h-4 mr-2" />
|
||||
Nouvel événement
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
{/* Search */}
|
||||
<div className="flex-1 w-full">
|
||||
<div className="relative">
|
||||
<SearchIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Rechercher un événement..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10 h-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tablo Filter */}
|
||||
<div className="w-full lg:w-64">
|
||||
<Select
|
||||
value={selectedTabloId}
|
||||
onValueChange={(value) => setSelectedTabloId(value)}
|
||||
>
|
||||
<SelectTrigger className="w-full h-10" aria-label="Filtrer par tableau">
|
||||
<SelectValue placeholder="Tous les tableaux" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Tous les tableaux</SelectItem>
|
||||
{tablos?.map((tablo) => (
|
||||
<SelectItem key={tablo.id} value={tablo.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={twMerge(
|
||||
"w-2 h-2 rounded-full",
|
||||
tablo.color || "bg-muted-foreground"
|
||||
)}
|
||||
/>
|
||||
{tablo.name}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Status Filter */}
|
||||
<ButtonGroup orientation="horizontal">
|
||||
{statusOptions.map((option) => (
|
||||
<Button
|
||||
key={option.id}
|
||||
variant={statusFilter === option.id ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setStatusFilter(option.id as BookingStatus)}
|
||||
className="rounded-full"
|
||||
>
|
||||
{option.name}
|
||||
</Button>
|
||||
))}
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Events List */}
|
||||
<div className="bg-card rounded-lg shadow-sm border border-border">
|
||||
{tablosLoading || eventsLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
) : paginatedEvents.length === 0 ? (
|
||||
<div className="p-12 text-center">
|
||||
<CalendarIcon className="mx-auto h-12 w-12 text-muted-foreground" />
|
||||
<h3 className="mt-2 text-sm font-medium text-foreground">
|
||||
Aucun événement trouvé
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{searchTerm || statusFilter !== "all"
|
||||
? "Essayez de modifier vos filtres de recherche."
|
||||
: "Commencez par créer votre premier événement."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{paginatedEvents.map((event) => (
|
||||
<div
|
||||
key={event.event_id}
|
||||
className="p-6 hover:bg-muted transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
<Strong className="text-lg text-foreground truncate">
|
||||
{event.title || "Événement sans titre"}
|
||||
</Strong>
|
||||
{getEventStatusBadge(event)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4 text-sm text-muted-foreground mb-2">
|
||||
<span className="flex items-center">
|
||||
<CalendarIcon className="w-4 h-4 mr-1" />
|
||||
{formatEventDateTime(event)}
|
||||
</span>
|
||||
{event.tablo_name && (
|
||||
<span
|
||||
className={twMerge(
|
||||
"inline-flex items-center px-2 py-1 rounded-md text-xs font-medium",
|
||||
event.tablo_color,
|
||||
getTextColorFromTabloColor(event.tablo_color)
|
||||
)}
|
||||
>
|
||||
{event.tablo_name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{event.description && (
|
||||
<Text className="text-muted-foreground line-clamp-2">
|
||||
{event.description}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 ml-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleViewEvent(event)}
|
||||
>
|
||||
Détails
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{totalItems > 0 && (
|
||||
<div className="bg-card rounded-lg shadow-sm border border-border px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4 text-sm text-muted-foreground">
|
||||
<span>
|
||||
Affichage de {startIndex + 1} à {Math.min(endIndex, totalItems)} sur{" "}
|
||||
{totalItems} événements
|
||||
</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="whitespace-nowrap">Éléments par page:</span>
|
||||
<Select
|
||||
value={itemsPerPage.toString()}
|
||||
onValueChange={(value) => setItemsPerPage(Number(value))}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="min-w-16 h-8"
|
||||
aria-label="Nombre d'éléments par page"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="5">5</SelectItem>
|
||||
<SelectItem value="10">10</SelectItem>
|
||||
<SelectItem value="20">20</SelectItem>
|
||||
<SelectItem value="50">50</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Précédent
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center space-x-1">
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||
.filter((page) => {
|
||||
return (
|
||||
page === 1 || page === totalPages || Math.abs(page - currentPage) <= 1
|
||||
);
|
||||
})
|
||||
.map((page, index, array) => {
|
||||
const prevPage = array[index - 1];
|
||||
const showEllipsis = prevPage && page - prevPage > 1;
|
||||
|
||||
return (
|
||||
<div key={page} className="flex items-center">
|
||||
{showEllipsis && (
|
||||
<span className="px-2 text-muted-foreground">...</span>
|
||||
)}
|
||||
<Button
|
||||
variant={currentPage === page ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(page)}
|
||||
className={
|
||||
currentPage === page
|
||||
? "bg-emerald-700 text-white hover:bg-emerald-600"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((prev) => Math.min(prev + 1, totalPages))}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
Suivant
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats Summary */}
|
||||
{filteredEvents.length > 0 && (
|
||||
<div className="bg-card rounded-lg shadow-sm border border-border p-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-foreground">
|
||||
{filteredEvents.length}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">Événements trouvés</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-foreground">
|
||||
{
|
||||
filteredEvents.filter((e) => {
|
||||
if (!e.start_date) return false;
|
||||
const eventDate = new Date(e.start_date);
|
||||
return eventDate >= new Date();
|
||||
}).length
|
||||
}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">À venir</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-primary">
|
||||
{
|
||||
filteredEvents.filter((e) => {
|
||||
if (!e.start_date) return false;
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const eventDate = new Date(e.start_date);
|
||||
eventDate.setHours(0, 0, 0, 0);
|
||||
return eventDate.getTime() === today.getTime();
|
||||
}).length
|
||||
}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">Aujourd'hui</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Event Types Tab */}
|
||||
<TabsContent value="event-types" className="space-y-6">
|
||||
<div className="flex items-center justify-end mb-4">
|
||||
<Button size="lg" variant="default" onClick={handleCreateEventType}>
|
||||
<PlusIcon className="w-4 h-4 mr-2" /> Nouveau type
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
Aucun type d'appel configuré
|
||||
</Text>
|
||||
<Button variant="default" size="lg" onClick={handleCreateEventType}>
|
||||
<PlusIcon className="w-4 h-4 mr-2" /> Créer votre premier type
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Event Details Modal */}
|
||||
<EventDetailsModal
|
||||
event={selectedEvent}
|
||||
isOpen={isDetailsModalOpen}
|
||||
onClose={() => {
|
||||
setIsDetailsModalOpen(false);
|
||||
setSelectedEvent(null);
|
||||
}}
|
||||
onEdit={() => selectedEvent && handleEditEvent(selectedEvent)}
|
||||
canEdit={selectedEvent ? canEditEvent(selectedEvent) : false}
|
||||
/>
|
||||
|
||||
{/* Event Type Modal */}
|
||||
<EventTypeModal
|
||||
isModalOpen={isEventTypeModalOpen}
|
||||
setIsModalOpen={setIsEventTypeModalOpen}
|
||||
editingEventType={editingEventType}
|
||||
formData={eventTypeFormData as EventTypeConfig}
|
||||
setFormData={setEventTypeFormData}
|
||||
handleSaveEventType={handleSaveEventType}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -53,14 +53,15 @@ export const JoinPage = () => {
|
|||
joinTablo(
|
||||
{ token },
|
||||
{
|
||||
onSuccess: () => {
|
||||
navigate("/");
|
||||
onSuccess: ({ tablo_id }) => {
|
||||
navigate(`/tablos/${tablo_id}`);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error(error);
|
||||
toast.add(
|
||||
{
|
||||
title: "Erreur",
|
||||
description: error.message,
|
||||
description: "Une erreur est survenue.",
|
||||
type: "error",
|
||||
},
|
||||
{ timeout: 2000 }
|
||||
|
|
|
|||
180
apps/main/src/pages/tablo-details.tsx
Normal file
180
apps/main/src/pages/tablo-details.tsx
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import { toast } from "@xtablo/shared";
|
||||
import { TabloUpdate, UserTablo } from "@xtablo/shared/types/tablos.types";
|
||||
import { Button } from "@xtablo/ui/components/button";
|
||||
import { ArrowLeft, FileText, MessageSquare, Settings } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { LoadingSpinner } from "../components/LoadingSpinner";
|
||||
import { TabloDiscussionSection } from "../components/TabloDiscussionSection";
|
||||
import { TabloFilesSection } from "../components/TabloFilesSection";
|
||||
import { TabloSettingsSection } from "../components/TabloSettingsSection";
|
||||
import { useTablosList, useUpdateTablo } from "../hooks/tablos";
|
||||
|
||||
type TabSection = "files" | "discussion" | "settings";
|
||||
|
||||
export const TabloDetailsPage = () => {
|
||||
const { tabloId } = useParams<{ tabloId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { data: tablos, isLoading } = useTablosList();
|
||||
const { mutateAsync: updateTablo } = useUpdateTablo();
|
||||
|
||||
const [activeSection, setActiveSection] = useState<TabSection>("files");
|
||||
const [tablo, setTablo] = useState<UserTablo | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (tablos && tabloId) {
|
||||
const foundTablo = tablos.find((t) => t.id === tabloId);
|
||||
if (foundTablo) {
|
||||
setTablo(foundTablo);
|
||||
} else {
|
||||
// Tablo not found, redirect back
|
||||
toast.add(
|
||||
{
|
||||
title: "Tablo introuvable",
|
||||
description: "Le tablo demandé n'existe pas ou vous n'y avez pas accès",
|
||||
type: "error",
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
navigate("/tablo");
|
||||
}
|
||||
}
|
||||
}, [tablos, tabloId, navigate]);
|
||||
|
||||
const handleEdit = async (updatedTablo: TabloUpdate & { id: string }) => {
|
||||
try {
|
||||
await updateTablo(updatedTablo);
|
||||
toast.add(
|
||||
{
|
||||
title: "Tablo mis à jour",
|
||||
description: "Les modifications ont été enregistrées",
|
||||
type: "success",
|
||||
},
|
||||
{ timeout: 3000 }
|
||||
);
|
||||
} catch (_error) {
|
||||
toast.add(
|
||||
{
|
||||
title: "Erreur",
|
||||
description: "Impossible de mettre à jour le tablo",
|
||||
type: "error",
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!tablo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isAdmin = tablo.is_admin;
|
||||
|
||||
const navigationItems: Array<{
|
||||
id: TabSection;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
}> = [
|
||||
{
|
||||
id: "files",
|
||||
label: "Fichiers",
|
||||
icon: <FileText className="w-5 h-5" />,
|
||||
},
|
||||
{
|
||||
id: "discussion",
|
||||
label: "Discussion",
|
||||
icon: <MessageSquare className="w-5 h-5" />,
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
label: "Paramètres",
|
||||
icon: <Settings className="w-5 h-5" />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-background">
|
||||
{/* Left Sidebar Navigation */}
|
||||
<aside className="w-64 border-r border-border bg-card flex flex-col">
|
||||
{/* Header with back button */}
|
||||
<div className="p-4 border-b border-border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate("/tablo")}
|
||||
className="mb-4 w-full justify-start"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Retour aux tablos
|
||||
</Button>
|
||||
|
||||
{/* Tablo preview */}
|
||||
<div className="flex items-center space-x-3">
|
||||
{tablo.image ? (
|
||||
<img
|
||||
src={tablo.image}
|
||||
alt={tablo.name}
|
||||
className="w-12 h-12 rounded-lg object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={`w-12 h-12 rounded-lg ${
|
||||
tablo.color || "bg-blue-500"
|
||||
} flex items-center justify-center`}
|
||||
>
|
||||
<span className="text-white font-bold text-sm">
|
||||
{tablo.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-lg font-bold text-foreground truncate">{tablo.name}</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isAdmin ? "Administrateur" : "Invité"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation items */}
|
||||
<nav className="flex-1 p-4 space-y-1">
|
||||
{navigationItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => setActiveSection(item.id)}
|
||||
className={`w-full flex items-center space-x-3 px-4 py-3 rounded-lg transition-colors ${
|
||||
activeSection === item.id
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-foreground hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
{item.icon}
|
||||
<span className="font-medium">{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main className="flex-1 overflow-auto">
|
||||
<div className="max-w-5xl mx-auto p-6 h-full">
|
||||
{activeSection === "files" && <TabloFilesSection tablo={tablo} isAdmin={isAdmin} />}
|
||||
{activeSection === "discussion" && (
|
||||
<TabloDiscussionSection tablo={tablo} isAdmin={isAdmin} />
|
||||
)}
|
||||
{activeSection === "settings" && (
|
||||
<TabloSettingsSection tablo={tablo} isAdmin={isAdmin} onEdit={handleEdit} />
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,9 +1,7 @@
|
|||
import { CreateTabloModal } from "@ui/components/CreateTabloModal";
|
||||
import { DeleteTabloModal } from "@ui/components/DeleteTabloModal";
|
||||
import { LoadingSpinner } from "@ui/components/LoadingSpinner";
|
||||
import { TabloModal } from "@ui/components/TabloModal";
|
||||
import { TabloTutorial } from "@ui/components/TabloTutorial";
|
||||
import { TabloInsert, TabloUpdate, UserTablo } from "@xtablo/shared/types/tablos.types";
|
||||
import { TabloInsert, UserTablo } from "@xtablo/shared/types/tablos.types";
|
||||
import { Button } from "@xtablo/ui/components/button";
|
||||
import {
|
||||
Empty,
|
||||
|
|
@ -25,7 +23,6 @@ import {
|
|||
CheckCircle2,
|
||||
Clock,
|
||||
Eye,
|
||||
HelpCircle,
|
||||
LayoutGrid,
|
||||
List,
|
||||
ListTodo,
|
||||
|
|
@ -34,7 +31,7 @@ import {
|
|||
Trash2,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useCreateTablo, useDeleteTablo, useTablosList, useUpdateTablo } from "../hooks/tablos";
|
||||
|
||||
|
|
@ -57,12 +54,9 @@ export const TabloPage = () => {
|
|||
y: number;
|
||||
} | null>(null);
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [viewingTablo, setViewingTablo] = useState<UserTablo | null>(null);
|
||||
const [deletingTablo, setDeletingTablo] = useState<UserTablo | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [filterType, setFilterType] = useState<"all" | "todo" | "in_progress" | "done">("all");
|
||||
const [isTutorialOpen, setIsTutorialOpen] = useState(false);
|
||||
const [hasInteractedWithTutorial, setHasInteractedWithTutorial] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
|
|
@ -81,20 +75,6 @@ export const TabloPage = () => {
|
|||
const { mutateAsync: updateTablo } = useUpdateTablo();
|
||||
const { mutateAsync: deleteTablo } = useDeleteTablo();
|
||||
|
||||
// Check if tutorial should be shown
|
||||
useEffect(() => {
|
||||
const tutorialCompleted = localStorage.getItem("xtablo-tutorial-completed");
|
||||
const tutorialInteracted = localStorage.getItem("xtablo-tutorial-interacted");
|
||||
|
||||
// Show tutorial if user hasn't completed it and has no tablos
|
||||
if (!tutorialCompleted && !isLoading && tablos && tablos.length === 0) {
|
||||
setIsTutorialOpen(true);
|
||||
}
|
||||
|
||||
// Check if user has interacted with tutorial before
|
||||
setHasInteractedWithTutorial(!!tutorialInteracted);
|
||||
}, [tablos, isLoading]);
|
||||
|
||||
// Filter tablos based on status
|
||||
const filteredTablos = tablos?.filter((tablo) => {
|
||||
if (filterType === "todo") {
|
||||
|
|
@ -143,15 +123,7 @@ export const TabloPage = () => {
|
|||
};
|
||||
|
||||
const openTablo = (tabloId: string) => {
|
||||
if (!tablos) return;
|
||||
const tablo = tablos.find((t) => t.id === tabloId);
|
||||
if (tablo) {
|
||||
setViewingTablo(tablo);
|
||||
}
|
||||
};
|
||||
|
||||
const closeTabloModal = () => {
|
||||
setViewingTablo(null);
|
||||
navigate(`/tablos/${tabloId}`);
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
|
|
@ -193,14 +165,6 @@ export const TabloPage = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const onEditTablo = (tablo: TabloUpdate & { id: string }) => {
|
||||
updateTablo(tablo, {
|
||||
onSuccess: () => {
|
||||
closeTabloModal();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteTablo = (tabloId: string) => {
|
||||
if (!tablos) return;
|
||||
const tablo = tablos.find((t) => t.id === tabloId);
|
||||
|
|
@ -226,20 +190,6 @@ export const TabloPage = () => {
|
|||
setIsDeleting(false);
|
||||
};
|
||||
|
||||
const handleCloseTutorial = () => {
|
||||
setIsTutorialOpen(false);
|
||||
};
|
||||
|
||||
const handleOpenTutorial = () => {
|
||||
setIsTutorialOpen(true);
|
||||
setHasInteractedWithTutorial(true);
|
||||
localStorage.setItem("xtablo-tutorial-interacted", "true");
|
||||
};
|
||||
|
||||
const handleTutorialCreateTablo = () => {
|
||||
setIsCreateModalOpen(true);
|
||||
};
|
||||
|
||||
const getUserRole = (tablo: UserTablo) => {
|
||||
return tablo.is_admin ? "Admin" : "Invité";
|
||||
};
|
||||
|
|
@ -689,29 +639,6 @@ export const TabloPage = () => {
|
|||
<TypographyMuted>Gérez vos projets et collaborations</TypographyMuted>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{!hasInteractedWithTutorial && (
|
||||
<div className="flex items-center gap-1 px-3 py-1 bg-primary/10 rounded-full border border-primary/20 animate-pulse shadow-lg">
|
||||
<span className="text-sm font-medium text-primary">Avant de commencer</span>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`p-2 ${
|
||||
!hasInteractedWithTutorial
|
||||
? "animate-pulse bg-primary/10 rounded-lg shadow-lg ring-2 ring-primary/20"
|
||||
: ""
|
||||
}`}
|
||||
onClick={handleOpenTutorial}
|
||||
title="Aide - Revoir le guide"
|
||||
>
|
||||
<HelpCircle
|
||||
className={`w-5 h-5 ${!hasInteractedWithTutorial ? "text-primary" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filter Controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
|
|
@ -921,11 +848,6 @@ export const TabloPage = () => {
|
|||
<CreateTabloModal onClose={closeCreateModal} onCreate={createNewTablo} />
|
||||
)}
|
||||
|
||||
{/* Tablo Details Modal */}
|
||||
{!!viewingTablo && (
|
||||
<TabloModal tablo={viewingTablo} onEdit={onEditTablo} onClose={closeTabloModal} />
|
||||
)}
|
||||
|
||||
{/* Delete Tablo Modal */}
|
||||
{!!deletingTablo && (
|
||||
<DeleteTabloModal
|
||||
|
|
@ -936,12 +858,12 @@ export const TabloPage = () => {
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* Tutorial */}
|
||||
<TabloTutorial
|
||||
{/* Tutorial - Hidden */}
|
||||
{/* <TabloTutorial
|
||||
isOpen={isTutorialOpen}
|
||||
onClose={handleCloseTutorial}
|
||||
onCreateTablo={handleTutorialCreateTablo}
|
||||
/>
|
||||
/> */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -18,7 +18,7 @@ export default defineConfig(({ mode }) => {
|
|||
|
||||
// Only include cloudflare plugin when not in test mode
|
||||
if (mode !== "test" && process.env.VITEST !== "true") {
|
||||
plugins.push(cloudflare());
|
||||
plugins.push(cloudflare({ inspectorPort: 9230 }));
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -61,6 +61,9 @@ importers:
|
|||
'@biomejs/biome':
|
||||
specifier: 2.2.5
|
||||
version: 2.2.5
|
||||
'@cloudflare/vite-plugin':
|
||||
specifier: ^1.9.4
|
||||
version: 1.13.14(vite@6.4.1(@types/node@22.18.12)(jiti@2.6.1)(lightningcss@1.30.2))(workerd@1.20251011.0)(wrangler@4.44.0)
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.0.14
|
||||
version: 4.1.15(vite@6.4.1(@types/node@22.18.12)(jiti@2.6.1)(lightningcss@1.30.2))
|
||||
|
|
|
|||
Loading…
Reference in a new issue