refactor: move tablo view components to packages/tablo-views
Moves kanban, gantt, section components, chat hooks and extracted EtapesSection/RoadmapSection from apps/main into the new shared packages/tablo-views package. Components that previously depended on app-specific providers (UserStoreProvider, useIsReadOnlyUser, etc.) are refactored to receive data/callbacks as props, keeping the package free of apps/main dependencies. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e10145d991
commit
bc28194d3d
24 changed files with 961 additions and 203 deletions
|
|
@ -35,6 +35,7 @@
|
|||
"@biomejs/biome": "2.2.5",
|
||||
"@types/react": "19.0.10",
|
||||
"@types/react-dom": "19.0.4",
|
||||
"typescript": "^5.7.0"
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^6.2.2"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
37
packages/tablo-views/src/ClickOutside.tsx
Normal file
37
packages/tablo-views/src/ClickOutside.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { useClickOutside } from "@xtablo/shared/hooks/useClickOutside";
|
||||
import React from "react";
|
||||
|
||||
interface ClickOutsideProps {
|
||||
children: React.ReactNode;
|
||||
onClickOutside: () => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that wraps children and detects clicks outside
|
||||
* @param children - The content to wrap
|
||||
* @param onClickOutside - Function to call when clicking outside
|
||||
* @param className - Optional className for the wrapper
|
||||
* @param disabled - Disable click outside detection
|
||||
*/
|
||||
export const ClickOutside: React.FC<ClickOutsideProps> = ({
|
||||
children,
|
||||
onClickOutside,
|
||||
className,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const ref = useClickOutside<HTMLDivElement>(
|
||||
disabled
|
||||
? () => {
|
||||
// Do nothing
|
||||
}
|
||||
: onClickOutside
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={className}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
366
packages/tablo-views/src/EtapesSection.tsx
Normal file
366
packages/tablo-views/src/EtapesSection.tsx
Normal file
|
|
@ -0,0 +1,366 @@
|
|||
import { cn } from "@xtablo/shared";
|
||||
import type { Etape, KanbanTask } from "@xtablo/shared-types";
|
||||
import { Button } from "@xtablo/ui/components/button";
|
||||
import { Input } from "@xtablo/ui/components/input";
|
||||
import {
|
||||
CalendarIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
CircleCheckIcon,
|
||||
ListChecksIcon,
|
||||
PlusIcon,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
interface EtapesSectionProps {
|
||||
etapes: Etape[];
|
||||
tabloTasks: KanbanTask[];
|
||||
tabloId: string;
|
||||
isAdmin: boolean;
|
||||
onCreateTask: (task: {
|
||||
tablo_id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
parent_task_id: string;
|
||||
is_parent: boolean;
|
||||
position: number;
|
||||
}) => void;
|
||||
onCreateEtape: (params: { tabloId: string; title: string; position: number }) => Promise<void>;
|
||||
isCreatingEtape?: boolean;
|
||||
}
|
||||
|
||||
export function EtapesSection({
|
||||
etapes,
|
||||
tabloTasks,
|
||||
tabloId,
|
||||
isAdmin,
|
||||
onCreateTask,
|
||||
onCreateEtape,
|
||||
isCreatingEtape = false,
|
||||
}: EtapesSectionProps) {
|
||||
const [expandedEtapes, setExpandedEtapes] = useState<Set<string>>(
|
||||
new Set(etapes.map((e) => e.id))
|
||||
);
|
||||
const [addingTaskToEtape, setAddingTaskToEtape] = useState<string | null>(null);
|
||||
const [newEtapeTitle, setNewEtapeTitle] = useState("");
|
||||
const [newTaskTitle, setNewTaskTitle] = useState("");
|
||||
|
||||
const toggleEtape = (id: string) => {
|
||||
setExpandedEtapes((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddTask = (etapeId: string) => {
|
||||
const title = newTaskTitle.trim();
|
||||
if (!title || !tabloId) return;
|
||||
onCreateTask({
|
||||
tablo_id: tabloId,
|
||||
title,
|
||||
status: "todo",
|
||||
parent_task_id: etapeId,
|
||||
is_parent: false,
|
||||
position: tabloTasks.filter((t) => t.parent_task_id === etapeId).length,
|
||||
});
|
||||
setNewTaskTitle("");
|
||||
setAddingTaskToEtape(null);
|
||||
};
|
||||
|
||||
const handleAddEtape = async () => {
|
||||
const title = newEtapeTitle.trim();
|
||||
if (!title || !tabloId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextPosition = etapes.reduce((max, etape) => Math.max(max, etape.position), -1) + 1;
|
||||
|
||||
await onCreateEtape({
|
||||
tabloId,
|
||||
title,
|
||||
position: nextPosition,
|
||||
});
|
||||
|
||||
setNewEtapeTitle("");
|
||||
};
|
||||
|
||||
const statusConfig: Record<string, { label: string; color: string }> = {
|
||||
todo: {
|
||||
label: "À faire",
|
||||
color: "bg-blue-100 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400",
|
||||
},
|
||||
in_progress: {
|
||||
label: "En cours",
|
||||
color: "bg-yellow-100 text-yellow-700 dark:bg-yellow-950/30 dark:text-yellow-400",
|
||||
},
|
||||
in_review: {
|
||||
label: "Vérification",
|
||||
color: "bg-purple-100 text-purple-700 dark:bg-purple-950/30 dark:text-purple-400",
|
||||
},
|
||||
done: {
|
||||
label: "Terminé",
|
||||
color: "bg-green-100 text-green-700 dark:bg-green-950/30 dark:text-green-400",
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{isAdmin && (
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
|
||||
<Input
|
||||
value={newEtapeTitle}
|
||||
onChange={(event) => setNewEtapeTitle(event.target.value)}
|
||||
placeholder="Nom de la nouvelle étape..."
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
void handleAddEtape();
|
||||
}
|
||||
}}
|
||||
className="h-11 sm:h-9 sm:w-80"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => void handleAddEtape()}
|
||||
disabled={isCreatingEtape || !newEtapeTitle.trim()}
|
||||
className="min-h-[44px] sm:min-h-0"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
Ajouter une étape
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{etapes.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||
<ListChecksIcon className="w-12 h-12 text-gray-300 dark:text-gray-600 mb-4" />
|
||||
<p className="text-gray-500 dark:text-gray-400 text-lg font-medium">Aucune étape</p>
|
||||
<p className="text-gray-400 dark:text-gray-500 text-sm mt-1">
|
||||
Les étapes permettent de structurer votre projet en grandes phases
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
etapes.map((etape, index) => {
|
||||
const childTasks = tabloTasks.filter((t) => t.parent_task_id === etape.id);
|
||||
const doneCount = childTasks.filter((t) => t.status === "done").length;
|
||||
const totalCount = childTasks.length;
|
||||
const progressPct = totalCount > 0 ? Math.round((doneCount / totalCount) * 100) : 0;
|
||||
const isExpanded = expandedEtapes.has(etape.id);
|
||||
|
||||
// Derive status from child tasks instead of etape.status
|
||||
const derivedStatus =
|
||||
totalCount === 0
|
||||
? "todo"
|
||||
: doneCount === totalCount
|
||||
? "done"
|
||||
: doneCount > 0
|
||||
? "in_progress"
|
||||
: "todo";
|
||||
const status = statusConfig[derivedStatus] ?? statusConfig.todo;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={etape.id}
|
||||
className="bg-white dark:bg-card rounded-xl border border-gray-100 dark:border-gray-700 shadow-sm overflow-hidden"
|
||||
>
|
||||
{/* Etape header */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleEtape(etape.id)}
|
||||
className="w-full flex items-center gap-3 sm:gap-4 px-3 sm:px-5 py-4 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors text-left min-h-[56px]"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDownIcon className="w-5 h-5 text-gray-400 shrink-0" />
|
||||
) : (
|
||||
<ChevronRightIcon className="w-5 h-5 text-gray-400 shrink-0" />
|
||||
)}
|
||||
|
||||
<div className="w-8 h-8 rounded-lg bg-[#F4F3FF] dark:bg-purple-900/20 flex items-center justify-center shrink-0">
|
||||
<span className="text-sm font-bold text-[#7F56D9] dark:text-purple-400">
|
||||
{index + 1}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100 truncate text-sm sm:text-base">
|
||||
{etape.title}
|
||||
</h3>
|
||||
{etape.description && (
|
||||
<p className="text-xs sm:text-sm text-muted-foreground truncate mt-0.5">
|
||||
{etape.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{etape.due_date && (
|
||||
<div
|
||||
className={cn(
|
||||
"items-center gap-1 text-xs hidden sm:flex",
|
||||
derivedStatus !== "done" &&
|
||||
new Date(etape.due_date) < new Date(new Date().toDateString())
|
||||
? "text-red-500"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="w-3.5 h-3.5" />
|
||||
<span>
|
||||
{new Intl.DateTimeFormat("fr-FR", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
}).format(new Date(etape.due_date))}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
"px-2 sm:px-2.5 py-1 rounded-full text-[10px] sm:text-xs font-medium",
|
||||
status.color
|
||||
)}
|
||||
>
|
||||
{status.label}
|
||||
</span>
|
||||
|
||||
{totalCount > 0 && (
|
||||
<div className="hidden sm:flex items-center gap-2">
|
||||
<div className="w-16 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-green-500 rounded-full transition-all"
|
||||
style={{ width: `${progressPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{doneCount}/{totalCount}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Child tasks + add task */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-gray-100 dark:border-gray-700">
|
||||
{childTasks.length > 0 && (
|
||||
<div className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{childTasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="flex items-center gap-3 px-3 sm:px-5 py-3 pl-8 sm:pl-16 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
{task.status === "done" ? (
|
||||
<CircleCheckIcon className="w-4 h-4 text-green-500 shrink-0" />
|
||||
) : (
|
||||
<div className="w-4 h-4 rounded-full border-2 border-gray-300 dark:border-gray-600 shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm flex-1 truncate",
|
||||
task.status === "done"
|
||||
? "line-through text-gray-400"
|
||||
: "text-gray-900 dark:text-gray-100"
|
||||
)}
|
||||
>
|
||||
{task.title}
|
||||
</span>
|
||||
{task.due_date && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1 text-xs shrink-0",
|
||||
task.status !== "done" &&
|
||||
new Date(task.due_date) < new Date(new Date().toDateString())
|
||||
? "text-red-500"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="w-3 h-3" />
|
||||
<span>
|
||||
{new Intl.DateTimeFormat("fr-FR", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
}).format(new Date(task.due_date))}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{task.status && (
|
||||
<span
|
||||
className={cn(
|
||||
"px-2 py-0.5 rounded-full text-[10px] font-medium shrink-0",
|
||||
(statusConfig[task.status] ?? statusConfig.todo).color
|
||||
)}
|
||||
>
|
||||
{(statusConfig[task.status] ?? statusConfig.todo).label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{childTasks.length === 0 && addingTaskToEtape !== etape.id && (
|
||||
<div className="px-3 sm:px-5 py-4 pl-8 sm:pl-16 text-sm text-muted-foreground">
|
||||
Aucune tâche dans cette étape
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inline add task */}
|
||||
{addingTaskToEtape === etape.id ? (
|
||||
<div className="flex items-center gap-2 px-3 sm:px-5 py-3 pl-8 sm:pl-16 border-t border-gray-100 dark:border-gray-700">
|
||||
<div className="w-4 h-4 rounded-full border-2 border-gray-300 dark:border-gray-600 shrink-0" />
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={newTaskTitle}
|
||||
onChange={(e) => setNewTaskTitle(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleAddTask(etape.id);
|
||||
if (e.key === "Escape") {
|
||||
setAddingTaskToEtape(null);
|
||||
setNewTaskTitle("");
|
||||
}
|
||||
}}
|
||||
placeholder="Nom de la tâche..."
|
||||
className="flex-1 text-sm bg-transparent border-none outline-none text-gray-900 dark:text-gray-100 placeholder-gray-400 min-w-0"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAddTask(etape.id)}
|
||||
disabled={!newTaskTitle.trim()}
|
||||
className="text-xs font-medium px-3 py-2 rounded-md bg-[#804EEC] text-white hover:bg-[#6f3fd4] disabled:opacity-40 transition-colors min-h-[36px] shrink-0"
|
||||
>
|
||||
Ajouter
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAddingTaskToEtape(null);
|
||||
setNewTaskTitle("");
|
||||
}}
|
||||
className="text-xs text-muted-foreground hover:text-foreground px-2 py-2 min-h-[36px] shrink-0"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setAddingTaskToEtape(etape.id);
|
||||
setNewTaskTitle("");
|
||||
}}
|
||||
className="flex items-center gap-2 px-3 sm:px-5 py-3 pl-8 sm:pl-16 text-sm text-muted-foreground hover:text-[#804EEC] hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors w-full text-left border-t border-gray-100 dark:border-gray-700 min-h-[44px]"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
Ajouter une tâche
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
114
packages/tablo-views/src/ImageColorPicker.tsx
Normal file
114
packages/tablo-views/src/ImageColorPicker.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
interface ImageColorPickerProps {
|
||||
creationMode: "image" | "color";
|
||||
setCreationMode: (mode: "image" | "color") => void;
|
||||
selectedColor: string;
|
||||
setSelectedColor: (color: string) => void;
|
||||
}
|
||||
|
||||
const AVAILABLE_COLORS = [
|
||||
"bg-blue-500",
|
||||
"bg-green-500",
|
||||
"bg-purple-500",
|
||||
"bg-red-500",
|
||||
"bg-yellow-500",
|
||||
"bg-indigo-500",
|
||||
"bg-pink-500",
|
||||
"bg-teal-500",
|
||||
"bg-orange-500",
|
||||
"bg-cyan-500",
|
||||
];
|
||||
|
||||
export const ImageColorPicker = ({
|
||||
creationMode,
|
||||
setCreationMode,
|
||||
selectedColor,
|
||||
setSelectedColor,
|
||||
}: ImageColorPickerProps) => {
|
||||
return (
|
||||
<div className="my-4 space-y-4">
|
||||
{/* Mode Toggle */}
|
||||
<div>
|
||||
<label className="block text-base font-semibold text-gray-800 dark:text-gray-300 mb-2">
|
||||
Style
|
||||
</label>
|
||||
<div className="flex rounded-md border border-gray-300 dark:border-gray-600 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium ${
|
||||
creationMode === "image"
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-gray-50 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600"
|
||||
} transition-colors`}
|
||||
onClick={() => setCreationMode("image")}
|
||||
>
|
||||
Image (Bientôt disponible)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium ${
|
||||
creationMode === "color"
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-gray-50 dark:bg-gray-700 text-gray-800 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600"
|
||||
} transition-colors`}
|
||||
onClick={() => setCreationMode("color")}
|
||||
>
|
||||
Couleur
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image Mode */}
|
||||
{creationMode === "image" && (
|
||||
<div className="space-y-4">
|
||||
{/* File Upload - Coming Soon */}
|
||||
<div className="bg-gray-50 dark:bg-gray-700 rounded-lg p-4 border border-dashed border-gray-300 dark:border-gray-600">
|
||||
<div className="text-center">
|
||||
<svg
|
||||
className="mx-auto h-8 w-8 text-gray-400"
|
||||
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>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
|
||||
<span className="font-medium">Import d'images</span>
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">Bientôt disponible</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Color Mode */}
|
||||
{creationMode === "color" && (
|
||||
<div>
|
||||
<label className="block text-base font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
Couleur
|
||||
</label>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{AVAILABLE_COLORS.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
className={`w-12 h-12 ${color} rounded-lg border-2 ${
|
||||
selectedColor === color
|
||||
? "border-gray-800 dark:border-white scale-110"
|
||||
: "border-gray-300 dark:border-gray-600"
|
||||
} hover:scale-105 transition-all duration-200`}
|
||||
onClick={() => setSelectedColor(color)}
|
||||
>
|
||||
{selectedColor === color && <span className="text-white text-lg">✓</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
23
packages/tablo-views/src/RoadmapSection.tsx
Normal file
23
packages/tablo-views/src/RoadmapSection.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import type { KanbanTask, TaskStatus } from "@xtablo/shared-types";
|
||||
import { GanttChart } from "./components/gantt/GanttChart";
|
||||
|
||||
interface RoadmapSectionProps {
|
||||
tabloTasks: KanbanTask[];
|
||||
onDateClick: (date: Date) => void;
|
||||
onTaskStatusChange: (taskId: string, status: TaskStatus) => void;
|
||||
}
|
||||
|
||||
export function RoadmapSection({
|
||||
tabloTasks,
|
||||
onDateClick,
|
||||
onTaskStatusChange,
|
||||
}: RoadmapSectionProps) {
|
||||
return (
|
||||
<GanttChart
|
||||
tasks={tabloTasks}
|
||||
isLoading={false}
|
||||
onDateClick={onDateClick}
|
||||
onTaskStatusChange={onTaskStatusChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,17 +1,26 @@
|
|||
import { UserTablo } from "@xtablo/shared/types/tablos.types";
|
||||
import type { UserTablo } from "@xtablo/shared/types/tablos.types";
|
||||
import { useEffect } from "react";
|
||||
import { useChat } from "../hooks/useChat";
|
||||
import { useTabloMembers } from "../hooks/tablos";
|
||||
import { useUser } from "../providers/UserStoreProvider";
|
||||
import { useChat } from "./hooks/useChat";
|
||||
import { ChatMessages } from "./ChatMessages";
|
||||
|
||||
interface Member {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar_url: string | null;
|
||||
}
|
||||
|
||||
interface TabloDiscussionSectionProps {
|
||||
tablo: UserTablo;
|
||||
isAdmin: boolean;
|
||||
currentUserId: string;
|
||||
members?: Member[];
|
||||
}
|
||||
|
||||
export const TabloDiscussionSection = ({ tablo }: TabloDiscussionSectionProps) => {
|
||||
const user = useUser();
|
||||
export const TabloDiscussionSection = ({
|
||||
tablo,
|
||||
currentUserId,
|
||||
members = [],
|
||||
}: TabloDiscussionSectionProps) => {
|
||||
const {
|
||||
messages,
|
||||
sendMessage,
|
||||
|
|
@ -22,8 +31,6 @@ export const TabloDiscussionSection = ({ tablo }: TabloDiscussionSectionProps) =
|
|||
markAsRead,
|
||||
} = useChat(tablo.id);
|
||||
|
||||
const { data: members = [] } = useTabloMembers(tablo.id);
|
||||
|
||||
// Mark as read when opening the discussion
|
||||
useEffect(() => {
|
||||
if (messages.length > 0) {
|
||||
|
|
@ -36,7 +43,7 @@ export const TabloDiscussionSection = ({ tablo }: TabloDiscussionSectionProps) =
|
|||
<div className="flex-1 overflow-hidden min-h-0">
|
||||
<ChatMessages
|
||||
messages={messages}
|
||||
currentUserId={user.id}
|
||||
currentUserId={currentUserId}
|
||||
members={members}
|
||||
typingUsers={typingUsers}
|
||||
hasMoreMessages={hasMoreMessages}
|
||||
|
|
@ -1,23 +1,74 @@
|
|||
import { UserTablo } from "@xtablo/shared/types/tablos.types";
|
||||
import type { UserTablo } from "@xtablo/shared/types/tablos.types";
|
||||
import { Button } from "@xtablo/ui/components/button";
|
||||
import { TypographyH3, TypographyMuted } from "@xtablo/ui/components/typography";
|
||||
import { Calendar, Clock, Plus } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useEventsByTablo } from "../hooks/events";
|
||||
import { useIsReadOnlyUser } from "../providers/UserStoreProvider";
|
||||
import { TabloHeaderActions } from "./TabloHeaderActions";
|
||||
|
||||
interface TabloEvent {
|
||||
event_id: string;
|
||||
title: string;
|
||||
start_date: string;
|
||||
end_date?: string | null;
|
||||
start_time?: string | null;
|
||||
end_time?: string | null;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
interface CurrentUser {
|
||||
id: string;
|
||||
avatar_url?: string | null;
|
||||
}
|
||||
|
||||
interface PendingInvite {
|
||||
id: string;
|
||||
invited_email: string;
|
||||
}
|
||||
|
||||
interface Member {
|
||||
id: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
avatar_url?: string | null;
|
||||
is_admin?: boolean;
|
||||
}
|
||||
|
||||
interface TabloEventsSectionProps {
|
||||
tablo: UserTablo;
|
||||
isAdmin: boolean;
|
||||
isReadOnly?: boolean;
|
||||
events?: TabloEvent[];
|
||||
isLoading?: boolean;
|
||||
error?: Error | null;
|
||||
currentUser: CurrentUser;
|
||||
members?: Member[];
|
||||
pendingInvites?: PendingInvite[];
|
||||
isInvitingUser?: boolean;
|
||||
isCancellingInvite?: boolean;
|
||||
onCreateEvent?: () => void;
|
||||
onUpdateTablo?: (data: { id: string; name?: string | null; color?: string | null }) => Promise<void>;
|
||||
onInviteUser?: (params: { email: string; tablo_id: string }) => void;
|
||||
onCancelInvite?: (params: { tabloId: string; inviteId: string }) => void;
|
||||
}
|
||||
|
||||
export const TabloEventsSection = ({ tablo, isAdmin }: TabloEventsSectionProps) => {
|
||||
const navigate = useNavigate();
|
||||
export const TabloEventsSection = ({
|
||||
tablo,
|
||||
isAdmin,
|
||||
isReadOnly = false,
|
||||
events,
|
||||
isLoading,
|
||||
error,
|
||||
currentUser,
|
||||
members,
|
||||
pendingInvites,
|
||||
isInvitingUser,
|
||||
isCancellingInvite,
|
||||
onCreateEvent,
|
||||
onUpdateTablo,
|
||||
onInviteUser,
|
||||
onCancelInvite,
|
||||
}: TabloEventsSectionProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { data: events, isLoading, error } = useEventsByTablo(tablo.id);
|
||||
const isReadOnly = useIsReadOnlyUser();
|
||||
|
||||
// Filter upcoming events (events in the future or today)
|
||||
const today = new Date();
|
||||
|
|
@ -34,10 +85,6 @@ export const TabloEventsSection = ({ tablo, isAdmin }: TabloEventsSectionProps)
|
|||
return (a.start_time || "").localeCompare(b.start_time || "");
|
||||
});
|
||||
|
||||
const handleCreateEvent = () => {
|
||||
navigate(`/planning/create?tablo_id=${tablo.id}`);
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
return new Intl.DateTimeFormat("fr-FR", {
|
||||
|
|
@ -50,7 +97,6 @@ export const TabloEventsSection = ({ tablo, isAdmin }: TabloEventsSectionProps)
|
|||
|
||||
const formatTime = (timeStr: string) => {
|
||||
if (!timeStr) return "";
|
||||
|
||||
return timeStr.slice(0, 5); // HH:MM
|
||||
};
|
||||
|
||||
|
|
@ -66,7 +112,7 @@ export const TabloEventsSection = ({ tablo, isAdmin }: TabloEventsSectionProps)
|
|||
</TypographyMuted>
|
||||
{!isReadOnly && (
|
||||
<Button
|
||||
onClick={handleCreateEvent}
|
||||
onClick={onCreateEvent}
|
||||
className="flex items-center gap-2 mt-4 bg-[#804EEC] hover:bg-[#6f3fd4] text-white"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
|
|
@ -74,7 +120,18 @@ export const TabloEventsSection = ({ tablo, isAdmin }: TabloEventsSectionProps)
|
|||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<TabloHeaderActions tablo={tablo} isAdmin={isAdmin} />
|
||||
<TabloHeaderActions
|
||||
tablo={tablo}
|
||||
isAdmin={isAdmin}
|
||||
currentUser={currentUser}
|
||||
members={members}
|
||||
pendingInvites={pendingInvites}
|
||||
isInvitingUser={isInvitingUser}
|
||||
isCancellingInvite={isCancellingInvite}
|
||||
onUpdateTablo={onUpdateTablo}
|
||||
onInviteUser={onInviteUser}
|
||||
onCancelInvite={onCancelInvite}
|
||||
/>
|
||||
</div>
|
||||
{/* Events List */}
|
||||
<div className="bg-card rounded-lg border border-border">
|
||||
|
|
@ -176,7 +233,7 @@ export const TabloEventsSection = ({ tablo, isAdmin }: TabloEventsSectionProps)
|
|||
</p>
|
||||
{!isReadOnly && (
|
||||
<Button
|
||||
onClick={handleCreateEvent}
|
||||
onClick={onCreateEvent}
|
||||
className="mt-4 bg-[#804EEC] hover:bg-[#6f3fd4] text-white"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { toast } from "@xtablo/shared";
|
||||
import { UserTablo } from "@xtablo/shared/types/tablos.types";
|
||||
import type { UserTablo } from "@xtablo/shared/types/tablos.types";
|
||||
import type { TabloFolder } from "@xtablo/shared-types";
|
||||
import { Button } from "@xtablo/ui/components/button";
|
||||
import {
|
||||
Collapsible,
|
||||
|
|
@ -26,28 +27,22 @@ import {
|
|||
Trash2Icon,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
useCreateTabloFile,
|
||||
useDeleteTabloFile,
|
||||
useDownloadTabloFile,
|
||||
useTabloFileNames,
|
||||
} from "../hooks/tablo_data";
|
||||
import {
|
||||
extractFolderIdFromFileName,
|
||||
getFileNameWithoutFolder,
|
||||
getFolderFilePrefix,
|
||||
TabloFolder,
|
||||
useCreateTabloFolder,
|
||||
useDeleteTabloFolder,
|
||||
useTabloFolders,
|
||||
useUpdateTabloFolder,
|
||||
} from "../hooks/tablo_folders";
|
||||
import { useIsReadOnlyUser, useUser } from "../providers/UserStoreProvider";
|
||||
import { TabloHeaderActions } from "./TabloHeaderActions";
|
||||
|
||||
interface TabloFilesSectionProps {
|
||||
tablo: UserTablo;
|
||||
isAdmin: boolean;
|
||||
// Helper to extract folder ID from a file name
|
||||
function extractFolderIdFromFileName(fileName: string): string | null {
|
||||
const match = fileName.match(/^folder_([^_]+)_/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
// Helper to strip folder prefix from file name
|
||||
function getFileNameWithoutFolder(fileName: string): string {
|
||||
return fileName.replace(/^folder_[^_]+_/, "");
|
||||
}
|
||||
|
||||
// Helper to build folder file prefix
|
||||
function getFolderFilePrefix(folderId: string): string {
|
||||
return `folder_${folderId}_`;
|
||||
}
|
||||
|
||||
// Helper to get file icon color based on extension
|
||||
|
|
@ -202,7 +197,6 @@ const FolderDialog = ({
|
|||
}
|
||||
};
|
||||
|
||||
// Reset form when dialog opens
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (open) {
|
||||
setName(folder?.name || "");
|
||||
|
|
@ -453,19 +447,81 @@ const FolderSection = ({
|
|||
);
|
||||
};
|
||||
|
||||
export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) => {
|
||||
const currentUser = useUser();
|
||||
const {
|
||||
data: fileData,
|
||||
isLoading: filesLoading,
|
||||
error: filesError,
|
||||
} = useTabloFileNames(tablo.id);
|
||||
const {
|
||||
data: foldersData,
|
||||
isLoading: foldersLoading,
|
||||
error: foldersError,
|
||||
} = useTabloFolders(tablo.id);
|
||||
interface CurrentUser {
|
||||
id: string;
|
||||
avatar_url?: string | null;
|
||||
}
|
||||
|
||||
interface PendingInvite {
|
||||
id: string;
|
||||
invited_email: string;
|
||||
}
|
||||
|
||||
interface Member {
|
||||
id: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
avatar_url?: string | null;
|
||||
is_admin?: boolean;
|
||||
}
|
||||
|
||||
interface TabloFilesSectionProps {
|
||||
tablo: UserTablo;
|
||||
isAdmin: boolean;
|
||||
isReadOnly?: boolean;
|
||||
currentUserId: string;
|
||||
fileNames?: string[];
|
||||
filesLoading?: boolean;
|
||||
filesError?: Error | null;
|
||||
folders?: TabloFolder[];
|
||||
foldersLoading?: boolean;
|
||||
foldersError?: Error | null;
|
||||
currentUser: CurrentUser;
|
||||
members?: Member[];
|
||||
pendingInvites?: PendingInvite[];
|
||||
isInvitingUser?: boolean;
|
||||
isCancellingInvite?: boolean;
|
||||
isCreatingFolder?: boolean;
|
||||
isUpdatingFolder?: boolean;
|
||||
onCreateFile?: (params: { tabloId: string; fileName: string; data: { content: string; contentType: string } }) => Promise<void>;
|
||||
onDeleteFile?: (params: { tabloId: string; fileName: string }) => Promise<void>;
|
||||
onDownloadFile?: (params: { tabloId: string; fileName: string }) => Promise<void>;
|
||||
onCreateFolder?: (params: { tabloId: string; name: string; description: string; createdBy: string }) => Promise<void>;
|
||||
onUpdateFolder?: (params: { tabloId: string; folderId: string; name: string; description: string }) => Promise<void>;
|
||||
onDeleteFolder?: (params: { tabloId: string; folderId: string; folderName: string }) => Promise<void>;
|
||||
onUpdateTablo?: (data: { id: string; name?: string | null; color?: string | null }) => Promise<void>;
|
||||
onInviteUser?: (params: { email: string; tablo_id: string }) => void;
|
||||
onCancelInvite?: (params: { tabloId: string; inviteId: string }) => void;
|
||||
}
|
||||
|
||||
export const TabloFilesSection = ({
|
||||
tablo,
|
||||
isAdmin,
|
||||
isReadOnly = false,
|
||||
currentUserId,
|
||||
fileNames,
|
||||
filesLoading,
|
||||
filesError,
|
||||
folders = [],
|
||||
foldersLoading,
|
||||
foldersError,
|
||||
currentUser,
|
||||
members,
|
||||
pendingInvites,
|
||||
isInvitingUser,
|
||||
isCancellingInvite,
|
||||
isCreatingFolder = false,
|
||||
isUpdatingFolder = false,
|
||||
onCreateFile,
|
||||
onDeleteFile,
|
||||
onDownloadFile,
|
||||
onCreateFolder,
|
||||
onUpdateFolder,
|
||||
onDeleteFolder,
|
||||
onUpdateTablo,
|
||||
onInviteUser,
|
||||
onCancelInvite,
|
||||
}: TabloFilesSectionProps) => {
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadingToFolder, setUploadingToFolder] = useState<string | null>(null);
|
||||
|
|
@ -477,27 +533,18 @@ export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) =>
|
|||
const [editingFolder, setEditingFolder] = useState<TabloFolder | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const createFile = useCreateTabloFile();
|
||||
const deleteFile = useDeleteTabloFile();
|
||||
const downloadFile = useDownloadTabloFile();
|
||||
const createFolder = useCreateTabloFolder();
|
||||
const updateFolder = useUpdateTabloFolder();
|
||||
const deleteFolder = useDeleteTabloFolder();
|
||||
const isReadOnly = useIsReadOnlyUser();
|
||||
const folders = foldersData?.folders || [];
|
||||
const folderIds = useMemo(() => new Set(folders.map((folder) => folder.id)), [folders]);
|
||||
|
||||
// Organize files by folder
|
||||
const { filesInFolders, unorganizedFiles } = useMemo(() => {
|
||||
if (!fileData?.fileNames) {
|
||||
if (!fileNames) {
|
||||
return { filesInFolders: new Map<string, string[]>(), unorganizedFiles: [] };
|
||||
}
|
||||
|
||||
const filesInFolders = new Map<string, string[]>();
|
||||
const unorganizedFiles: string[] = [];
|
||||
|
||||
for (const fileName of fileData.fileNames) {
|
||||
// Skip metadata files
|
||||
for (const fileName of fileNames) {
|
||||
if (fileName.startsWith(".")) continue;
|
||||
|
||||
const folderId = extractFolderIdFromFileName(fileName);
|
||||
|
|
@ -511,7 +558,7 @@ export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) =>
|
|||
}
|
||||
|
||||
return { filesInFolders, unorganizedFiles };
|
||||
}, [fileData?.fileNames, folderIds]);
|
||||
}, [fileNames, folderIds]);
|
||||
|
||||
const toggleFolder = (folderId: string) => {
|
||||
setOpenFolders((prev) => {
|
||||
|
|
@ -558,7 +605,7 @@ export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) =>
|
|||
? `${getFolderFilePrefix(targetFolderId)}${file.name}`
|
||||
: file.name;
|
||||
|
||||
await createFile.mutateAsync({
|
||||
await onCreateFile?.({
|
||||
tabloId: tablo.id,
|
||||
fileName,
|
||||
data: {
|
||||
|
|
@ -625,7 +672,7 @@ export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) =>
|
|||
|
||||
setDeletingFile(fileName);
|
||||
try {
|
||||
await deleteFile.mutateAsync({ tabloId: tablo.id, fileName });
|
||||
await onDeleteFile?.({ tabloId: tablo.id, fileName });
|
||||
} catch (error) {
|
||||
console.error("Delete error:", error);
|
||||
} finally {
|
||||
|
|
@ -638,7 +685,7 @@ export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) =>
|
|||
|
||||
setDownloadingFile(fileName);
|
||||
try {
|
||||
await downloadFile.mutateAsync({ tabloId: tablo.id, fileName });
|
||||
await onDownloadFile?.({ tabloId: tablo.id, fileName });
|
||||
} catch (error) {
|
||||
console.error("Download error:", error);
|
||||
} finally {
|
||||
|
|
@ -665,7 +712,7 @@ export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) =>
|
|||
|
||||
if (!window.confirm(confirmMessage)) return;
|
||||
|
||||
await deleteFolder.mutateAsync({
|
||||
await onDeleteFolder?.({
|
||||
tabloId: tablo.id,
|
||||
folderId: folder.id,
|
||||
folderName: folder.name,
|
||||
|
|
@ -674,18 +721,18 @@ export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) =>
|
|||
|
||||
const handleSaveFolder = async (name: string, description: string) => {
|
||||
if (editingFolder) {
|
||||
await updateFolder.mutateAsync({
|
||||
await onUpdateFolder?.({
|
||||
tabloId: tablo.id,
|
||||
folderId: editingFolder.id,
|
||||
name,
|
||||
description,
|
||||
});
|
||||
} else {
|
||||
await createFolder.mutateAsync({
|
||||
await onCreateFolder?.({
|
||||
tabloId: tablo.id,
|
||||
name,
|
||||
description,
|
||||
createdBy: currentUser?.id || "",
|
||||
createdBy: currentUserId,
|
||||
});
|
||||
}
|
||||
setIsFolderDialogOpen(false);
|
||||
|
|
@ -703,7 +750,18 @@ export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) =>
|
|||
Gérez les fichiers et livrables de ce tablo
|
||||
</TypographyMuted>
|
||||
</div>
|
||||
<TabloHeaderActions tablo={tablo} isAdmin={isAdmin} />
|
||||
<TabloHeaderActions
|
||||
tablo={tablo}
|
||||
isAdmin={isAdmin}
|
||||
currentUser={currentUser}
|
||||
members={members}
|
||||
pendingInvites={pendingInvites}
|
||||
isInvitingUser={isInvitingUser}
|
||||
isCancellingInvite={isCancellingInvite}
|
||||
onUpdateTablo={onUpdateTablo}
|
||||
onInviteUser={onInviteUser}
|
||||
onCancelInvite={onCancelInvite}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error Banner */}
|
||||
|
|
@ -987,7 +1045,7 @@ export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) =>
|
|||
}}
|
||||
onSave={handleSaveFolder}
|
||||
folder={editingFolder}
|
||||
isLoading={createFolder.isPending || updateFolder.isPending}
|
||||
isLoading={isCreatingFolder || isUpdatingFolder}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { toast } from "@xtablo/shared";
|
||||
import { TabloUpdate, UserTablo } from "@xtablo/shared/types/tablos.types";
|
||||
import type { TabloUpdate, UserTablo } from "@xtablo/shared/types/tablos.types";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@xtablo/ui/components/avatar";
|
||||
import { Button } from "@xtablo/ui/components/button";
|
||||
import {
|
||||
|
|
@ -13,21 +13,52 @@ import { Input } from "@xtablo/ui/components/input";
|
|||
import { Popover, PopoverContent, PopoverTrigger } from "@xtablo/ui/components/popover";
|
||||
import { Loader2, Settings, Share2, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useInviteUser } from "../hooks/invite";
|
||||
import { useCancelTabloInvite, usePendingTabloInvitesByTablo } from "../hooks/tablo_invites";
|
||||
import { useTabloMembers, useUpdateTablo } from "../hooks/tablos";
|
||||
import { useUser } from "../providers/UserStoreProvider";
|
||||
import { ClickOutside } from "./ClickOutside";
|
||||
import { ImageColorPicker } from "./ImageColorPicker";
|
||||
|
||||
interface PendingInvite {
|
||||
id: string;
|
||||
invited_email: string;
|
||||
}
|
||||
|
||||
interface Member {
|
||||
id: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
avatar_url?: string | null;
|
||||
is_admin?: boolean;
|
||||
}
|
||||
|
||||
interface CurrentUser {
|
||||
id: string;
|
||||
avatar_url?: string | null;
|
||||
}
|
||||
|
||||
interface TabloHeaderActionsProps {
|
||||
tablo: UserTablo;
|
||||
isAdmin: boolean;
|
||||
currentUser: CurrentUser;
|
||||
members?: Member[];
|
||||
pendingInvites?: PendingInvite[];
|
||||
isInvitingUser?: boolean;
|
||||
isCancellingInvite?: boolean;
|
||||
onUpdateTablo?: (data: TabloUpdate & { id: string }) => Promise<void>;
|
||||
onInviteUser?: (params: { email: string; tablo_id: string }) => void;
|
||||
onCancelInvite?: (params: { tabloId: string; inviteId: string }) => void;
|
||||
}
|
||||
|
||||
export const TabloHeaderActions = ({ tablo, isAdmin }: TabloHeaderActionsProps) => {
|
||||
const { mutateAsync: updateTablo } = useUpdateTablo();
|
||||
const currentUser = useUser();
|
||||
export const TabloHeaderActions = ({
|
||||
tablo,
|
||||
isAdmin,
|
||||
currentUser,
|
||||
members = [],
|
||||
pendingInvites = [],
|
||||
isInvitingUser = false,
|
||||
isCancellingInvite = false,
|
||||
onUpdateTablo,
|
||||
onInviteUser,
|
||||
onCancelInvite,
|
||||
}: TabloHeaderActionsProps) => {
|
||||
const [isShareDialogOpen, setIsShareDialogOpen] = useState(false);
|
||||
const [inviteEmail, setInviteEmail] = useState("");
|
||||
|
||||
|
|
@ -39,12 +70,6 @@ export const TabloHeaderActions = ({ tablo, isAdmin }: TabloHeaderActionsProps)
|
|||
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Fetch members and invites for share dialog
|
||||
const { data: members } = useTabloMembers(tablo?.id || "");
|
||||
const { data: pendingInvites } = usePendingTabloInvitesByTablo(tablo?.id || "");
|
||||
const { mutate: cancelInvite, isPending: isCancellingInvite } = useCancelTabloInvite();
|
||||
const { mutate: inviteUser, isPending: isInvitingUser } = useInviteUser();
|
||||
|
||||
useEffect(() => {
|
||||
setEditData(tablo);
|
||||
setSelectedColor(tablo.color || "bg-blue-500");
|
||||
|
|
@ -59,14 +84,14 @@ export const TabloHeaderActions = ({ tablo, isAdmin }: TabloHeaderActionsProps)
|
|||
}, [isEditingName]);
|
||||
|
||||
const handleSaveSettings = async () => {
|
||||
if (editData && tablo) {
|
||||
if (editData && tablo && onUpdateTablo) {
|
||||
const updatedTablo: TabloUpdate & { id: string } = {
|
||||
id: editData.id,
|
||||
name: editData.name,
|
||||
color: creationMode === "color" ? selectedColor : null,
|
||||
};
|
||||
try {
|
||||
await updateTablo(updatedTablo);
|
||||
await onUpdateTablo(updatedTablo);
|
||||
toast.add(
|
||||
{
|
||||
title: "Tablo mis à jour",
|
||||
|
|
@ -89,8 +114,8 @@ export const TabloHeaderActions = ({ tablo, isAdmin }: TabloHeaderActionsProps)
|
|||
};
|
||||
|
||||
const handleSendInvite = () => {
|
||||
if (inviteEmail.trim() && tablo) {
|
||||
inviteUser({ email: inviteEmail, tablo_id: tablo.id });
|
||||
if (inviteEmail.trim() && tablo && onInviteUser) {
|
||||
onInviteUser({ email: inviteEmail, tablo_id: tablo.id });
|
||||
setInviteEmail("");
|
||||
}
|
||||
};
|
||||
|
|
@ -278,7 +303,7 @@ export const TabloHeaderActions = ({ tablo, isAdmin }: TabloHeaderActionsProps)
|
|||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
onClick={() => cancelInvite({ tabloId: tablo.id, inviteId: invite.id })}
|
||||
onClick={() => onCancelInvite?.({ tabloId: tablo.id, inviteId: invite.id })}
|
||||
disabled={isCancellingInvite}
|
||||
title="Retirer l'invitation"
|
||||
>
|
||||
|
|
@ -1,37 +1,71 @@
|
|||
import { pluralize, toast } from "@xtablo/shared";
|
||||
import { UserTablo } from "@xtablo/shared/types/tablos.types";
|
||||
import type { KanbanColumn, KanbanTask, KanbanTaskInsert, TaskStatus } from "@xtablo/shared-types";
|
||||
import type { UserTablo } from "@xtablo/shared/types/tablos.types";
|
||||
import type {
|
||||
Etape,
|
||||
KanbanColumn,
|
||||
KanbanTask,
|
||||
KanbanTaskInsert,
|
||||
KanbanTaskUpdate,
|
||||
TaskStatus,
|
||||
} from "@xtablo/shared-types";
|
||||
import { TypographyH3, TypographyMuted } from "@xtablo/ui/components/typography";
|
||||
import { AlertTriangle, ListChecks } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTabloMembers } from "../hooks/tablos";
|
||||
import {
|
||||
useCreateTask,
|
||||
useTabloEtapes,
|
||||
useTasksByTablo,
|
||||
useUpdateTaskPositions,
|
||||
} from "../hooks/tasks";
|
||||
import { KanbanBoard } from "./kanban/KanbanBoard";
|
||||
import { TaskModal } from "./kanban/TaskModal";
|
||||
import { KanbanBoard } from "./components/kanban/KanbanBoard";
|
||||
import type { TabloMember } from "./components/kanban/types";
|
||||
import { TaskModal } from "./components/kanban/TaskModal";
|
||||
import { TabloHeaderActions } from "./TabloHeaderActions";
|
||||
|
||||
interface CurrentUser {
|
||||
id: string;
|
||||
avatar_url?: string | null;
|
||||
}
|
||||
|
||||
interface PendingInvite {
|
||||
id: string;
|
||||
invited_email: string;
|
||||
}
|
||||
|
||||
interface TabloTasksSectionProps {
|
||||
tablo: UserTablo;
|
||||
isAdmin: boolean;
|
||||
tasks?: KanbanTask[];
|
||||
members?: TabloMember[];
|
||||
etapes?: Etape[];
|
||||
currentUser: CurrentUser;
|
||||
pendingInvites?: PendingInvite[];
|
||||
isInvitingUser?: boolean;
|
||||
isCancellingInvite?: boolean;
|
||||
onCreateTask?: (task: KanbanTaskInsert) => void;
|
||||
onUpdateTask?: (task: KanbanTaskUpdate & { id: string; tablo_id: string }) => void;
|
||||
onUpdateTaskPositions?: (updates: Array<{ id: string; position: number; status: TaskStatus }>) => void;
|
||||
onUpdateTablo?: (data: { id: string; name?: string | null; color?: string | null }) => Promise<void>;
|
||||
onInviteUser?: (params: { email: string; tablo_id: string }) => void;
|
||||
onCancelInvite?: (params: { tabloId: string; inviteId: string }) => void;
|
||||
}
|
||||
|
||||
export const TabloTasksSection = ({ tablo, isAdmin }: TabloTasksSectionProps) => {
|
||||
const { data: members = [] } = useTabloMembers(tablo.id);
|
||||
export const TabloTasksSection = ({
|
||||
tablo,
|
||||
isAdmin,
|
||||
tasks,
|
||||
members = [],
|
||||
etapes = [],
|
||||
currentUser,
|
||||
pendingInvites,
|
||||
isInvitingUser,
|
||||
isCancellingInvite,
|
||||
onCreateTask,
|
||||
onUpdateTask,
|
||||
onUpdateTaskPositions,
|
||||
onUpdateTablo,
|
||||
onInviteUser,
|
||||
onCancelInvite,
|
||||
}: TabloTasksSectionProps) => {
|
||||
const [columns, setColumns] = useState<KanbanColumn[]>([]);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedTask, setSelectedTask] = useState<KanbanTask | null>(null);
|
||||
const [modalStatus, setModalStatus] = useState<TaskStatus>("todo");
|
||||
|
||||
const { data: tasks } = useTasksByTablo(tablo.id);
|
||||
const { data: etapes = [] } = useTabloEtapes(tablo.id);
|
||||
const { mutate: updateTaskPositions } = useUpdateTaskPositions();
|
||||
const { mutate: createTask } = useCreateTask();
|
||||
|
||||
const memberById = useMemo(
|
||||
() => new Map(members.map((member) => [member.id, member])),
|
||||
[members]
|
||||
|
|
@ -72,7 +106,6 @@ export const TabloTasksSection = ({ tablo, isAdmin }: TabloTasksSectionProps) =>
|
|||
return tasksWithAssigneeFallback.filter((task) => !task.parent_task_id);
|
||||
}, [tasksWithAssigneeFallback]);
|
||||
|
||||
// Helper functions defined before use
|
||||
const initializeColumns = useCallback((tasks: KanbanTask[]): KanbanColumn[] => {
|
||||
const defaultColumns: KanbanColumn[] = [
|
||||
{
|
||||
|
|
@ -137,19 +170,7 @@ export const TabloTasksSection = ({ tablo, isAdmin }: TabloTasksSectionProps) =>
|
|||
parent_task_id: taskData.parent_task_id ?? null,
|
||||
};
|
||||
|
||||
createTask(newTask);
|
||||
|
||||
// setColumns((prevColumns) =>
|
||||
// prevColumns.map((column: KanbanColumn) => {
|
||||
// if (column.status === (taskData.status as TaskStatus)) {
|
||||
// return {
|
||||
// ...column,
|
||||
// tasks: [newTask, ...column.tasks],
|
||||
// };
|
||||
// }
|
||||
// return column;
|
||||
// })
|
||||
// );
|
||||
onCreateTask?.(newTask);
|
||||
|
||||
toast.add(
|
||||
{
|
||||
|
|
@ -162,7 +183,7 @@ export const TabloTasksSection = ({ tablo, isAdmin }: TabloTasksSectionProps) =>
|
|||
};
|
||||
|
||||
const handleTaskMove = (taskId: string, newStatus: TaskStatus) => {
|
||||
updateTaskPositions([
|
||||
onUpdateTaskPositions?.([
|
||||
{
|
||||
id: taskId,
|
||||
position: columns.find((column) => column.status === newStatus)?.position ?? 0,
|
||||
|
|
@ -198,7 +219,18 @@ export const TabloTasksSection = ({ tablo, isAdmin }: TabloTasksSectionProps) =>
|
|||
Gérez vos tâches avec un tableau Kanban
|
||||
</TypographyMuted>
|
||||
</div>
|
||||
<TabloHeaderActions tablo={tablo} isAdmin={isAdmin} />
|
||||
<TabloHeaderActions
|
||||
tablo={tablo}
|
||||
isAdmin={isAdmin}
|
||||
currentUser={currentUser}
|
||||
members={members}
|
||||
pendingInvites={pendingInvites}
|
||||
isInvitingUser={isInvitingUser}
|
||||
isCancellingInvite={isCancellingInvite}
|
||||
onUpdateTablo={onUpdateTablo}
|
||||
onInviteUser={onInviteUser}
|
||||
onCancelInvite={onCancelInvite}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Warning for orphaned tasks */}
|
||||
|
|
@ -238,11 +270,14 @@ export const TabloTasksSection = ({ tablo, isAdmin }: TabloTasksSectionProps) =>
|
|||
<TaskModal
|
||||
tabloId={tablo.id}
|
||||
taskId={selectedTask?.id}
|
||||
task={selectedTask}
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
members={members}
|
||||
initialStatus={modalStatus}
|
||||
etapes={etapes}
|
||||
onCreateTask={onCreateTask}
|
||||
onUpdateTask={onUpdateTask}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
import { LoadingSpinner } from "@ui/components/LoadingSpinner";
|
||||
import type { KanbanTask, TaskStatus } from "@xtablo/shared-types";
|
||||
import { Button } from "@xtablo/ui/components/button";
|
||||
import {
|
||||
|
|
@ -253,7 +252,7 @@ export function GanttChart({ tasks, isLoading, onDateClick, onTaskStatusChange }
|
|||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<LoadingSpinner />
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -130,24 +130,6 @@ export const InlineTaskCreate = ({ status, members, etapes, onSubmit }: InlineTa
|
|||
|
||||
{/* Type and Assignee */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{/* <div className="space-y-1">
|
||||
<Label htmlFor="type" className="text-xs text-muted-foreground">
|
||||
Type
|
||||
</Label>
|
||||
<Select value={type} onValueChange={(value) => setType(value as TaskType)}>
|
||||
<SelectTrigger id="type" size="sm" className="w-full text-sm h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="task">Task</SelectItem>
|
||||
<SelectItem value="story">Story</SelectItem>
|
||||
<SelectItem value="bug">Bug</SelectItem>
|
||||
<SelectItem value="epic">Epic</SelectItem>
|
||||
<SelectItem value="subtask">Subtask</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div> */}
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="assignee" className="text-xs text-muted-foreground">
|
||||
Assigné à
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import type { UserTablo } from "@xtablo/shared/types/tablos.types";
|
||||
import type { Etape, TaskStatus } from "@xtablo/shared-types";
|
||||
import type { Etape, KanbanTask, KanbanTaskInsert, KanbanTaskUpdate, TaskStatus } from "@xtablo/shared-types";
|
||||
import { Button } from "@xtablo/ui/components/button";
|
||||
import { DatePicker } from "@xtablo/ui/components/date-picker";
|
||||
import { Input } from "@xtablo/ui/components/input";
|
||||
|
|
@ -15,21 +14,32 @@ import { Textarea } from "@xtablo/ui/components/textarea";
|
|||
import { TypographyH2 } from "@xtablo/ui/components/typography";
|
||||
import { X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTabloMembers } from "../../hooks/tablos";
|
||||
import { useCreateTask, useTabloEtapes, useTask, useUpdateTask } from "../../hooks/tasks";
|
||||
import type { TabloMember } from "./types";
|
||||
|
||||
/** Minimal UserTablo shape needed by this modal (tablo selector when creating). */
|
||||
interface MinimalTablo {
|
||||
id: string;
|
||||
name: string;
|
||||
color?: string | null;
|
||||
}
|
||||
|
||||
interface TaskModalProps {
|
||||
isOpen: boolean;
|
||||
tabloId?: string; // Optional when creating a task - can select tablo
|
||||
taskId?: string | undefined; // Optional - undefined when creating new task
|
||||
tabloId?: string;
|
||||
taskId?: string | undefined;
|
||||
onClose: () => void;
|
||||
members?: TabloMember[]; // Optional - will be fetched if tabloId is provided
|
||||
/** Task data when editing an existing task. */
|
||||
task?: KanbanTask | null;
|
||||
members?: TabloMember[];
|
||||
etapes?: Etape[];
|
||||
initialStatus?: TaskStatus;
|
||||
etapes?: Etape[]; // Optional - will be fetched if tabloId is provided
|
||||
tablos?: UserTablo[]; // Optional - for tablo selection when creating
|
||||
allowTabloSelection?: boolean; // Whether to show tablo selector
|
||||
tablos?: MinimalTablo[];
|
||||
allowTabloSelection?: boolean;
|
||||
initialDueDate?: Date;
|
||||
/** Called when creating a new task. */
|
||||
onCreateTask?: (task: KanbanTaskInsert) => void;
|
||||
/** Called when updating an existing task. */
|
||||
onUpdateTask?: (task: KanbanTaskUpdate & { id: string; tablo_id: string }) => void;
|
||||
}
|
||||
|
||||
export const TaskModal = ({
|
||||
|
|
@ -37,14 +47,16 @@ export const TaskModal = ({
|
|||
taskId,
|
||||
isOpen,
|
||||
onClose,
|
||||
members: providedMembers,
|
||||
task = null,
|
||||
members: providedMembers = [],
|
||||
initialStatus = "todo",
|
||||
etapes: providedEtapes,
|
||||
etapes: providedEtapes = [],
|
||||
tablos,
|
||||
allowTabloSelection = false,
|
||||
initialDueDate,
|
||||
onCreateTask,
|
||||
onUpdateTask,
|
||||
}: TaskModalProps) => {
|
||||
const { data: task = null } = useTask(taskId);
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [assigneeId, setAssigneeId] = useState<string>("unassigned");
|
||||
|
|
@ -54,16 +66,6 @@ export const TaskModal = ({
|
|||
initialTabloId || tablos?.[0]?.id || ""
|
||||
);
|
||||
|
||||
// Determine which tablo to use for fetching data
|
||||
const tabloIdForFetch = allowTabloSelection ? selectedTabloId : initialTabloId || "";
|
||||
|
||||
// Fetch members and etapes for selected tablo if not provided
|
||||
const { data: fetchedMembers = [] } = useTabloMembers(tabloIdForFetch || "");
|
||||
const { data: fetchedEtapes = [] } = useTabloEtapes(tabloIdForFetch || undefined);
|
||||
|
||||
// Use provided or fetched data
|
||||
const members = providedMembers || fetchedMembers;
|
||||
const etapes = providedEtapes || fetchedEtapes;
|
||||
const currentTabloId = allowTabloSelection ? selectedTabloId : initialTabloId || "";
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -77,7 +79,6 @@ export const TaskModal = ({
|
|||
setSelectedTabloId(task.tablo_id);
|
||||
}
|
||||
} else {
|
||||
// Reset form when creating new task
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setAssigneeId("unassigned");
|
||||
|
|
@ -89,9 +90,6 @@ export const TaskModal = ({
|
|||
}
|
||||
}, [task, initialTabloId, allowTabloSelection, tablos, initialDueDate]);
|
||||
|
||||
const { mutate: createTask } = useCreateTask();
|
||||
const { mutate: updateTask } = useUpdateTask();
|
||||
|
||||
// Format Date to YYYY-MM-DD string for database storage
|
||||
const formatDateForDb = (date: Date | undefined): string | null => {
|
||||
if (!date) return null;
|
||||
|
|
@ -104,12 +102,12 @@ export const TaskModal = ({
|
|||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!title.trim()) return;
|
||||
if (!currentTabloId) return; // Need a tablo to create task
|
||||
if (!currentTabloId) return;
|
||||
|
||||
const dueDateValue = formatDateForDb(dueDate);
|
||||
|
||||
if (taskId && task) {
|
||||
updateTask({
|
||||
onUpdateTask?.({
|
||||
tablo_id: task.tablo_id,
|
||||
id: task.id,
|
||||
title: title.trim(),
|
||||
|
|
@ -120,7 +118,7 @@ export const TaskModal = ({
|
|||
due_date: dueDateValue,
|
||||
});
|
||||
} else {
|
||||
createTask({
|
||||
onCreateTask?.({
|
||||
tablo_id: currentTabloId,
|
||||
title: title.trim(),
|
||||
description: description.trim(),
|
||||
|
|
@ -223,7 +221,7 @@ export const TaskModal = ({
|
|||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="unassigned">Non assigné</SelectItem>
|
||||
{members.map((member) => (
|
||||
{providedMembers.map((member) => (
|
||||
<SelectItem key={member.id} value={member.id}>
|
||||
{member.name}
|
||||
</SelectItem>
|
||||
|
|
@ -233,7 +231,7 @@ export const TaskModal = ({
|
|||
</div>
|
||||
|
||||
{/* Étape */}
|
||||
{etapes.length > 0 && (
|
||||
{providedEtapes.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="etape">Étape</Label>
|
||||
<Select value={etapeId} onValueChange={setEtapeId}>
|
||||
|
|
@ -242,7 +240,7 @@ export const TaskModal = ({
|
|||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">Aucune</SelectItem>
|
||||
{etapes.map((etape) => (
|
||||
{providedEtapes.map((etape) => (
|
||||
<SelectItem key={etape.id} value={etape.id}>
|
||||
{etape.title}
|
||||
</SelectItem>
|
||||
|
|
@ -1,21 +1,20 @@
|
|||
// Section components — will be populated as components are moved from apps/main
|
||||
// export { TabloTasksSection } from "./TabloTasksSection";
|
||||
// export { TabloFilesSection } from "./TabloFilesSection";
|
||||
// export { TabloDiscussionSection } from "./TabloDiscussionSection";
|
||||
// export { TabloEventsSection } from "./TabloEventsSection";
|
||||
// export { EtapesSection } from "./EtapesSection";
|
||||
// export { RoadmapSection } from "./RoadmapSection";
|
||||
// export { TabloHeaderActions } from "./TabloHeaderActions";
|
||||
// export { ChatMessages } from "./ChatMessages";
|
||||
export { TabloTasksSection } from "./TabloTasksSection";
|
||||
export { TabloFilesSection } from "./TabloFilesSection";
|
||||
export { TabloDiscussionSection } from "./TabloDiscussionSection";
|
||||
export { TabloEventsSection } from "./TabloEventsSection";
|
||||
export { EtapesSection } from "./EtapesSection";
|
||||
export { RoadmapSection } from "./RoadmapSection";
|
||||
export { TabloHeaderActions } from "./TabloHeaderActions";
|
||||
export { ChatMessages } from "./ChatMessages";
|
||||
|
||||
// Sub-components
|
||||
// export { GanttChart } from "./components/gantt/GanttChart";
|
||||
// export { TaskModal } from "./components/kanban/TaskModal";
|
||||
// export { KanbanBoard } from "./components/kanban/KanbanBoard";
|
||||
export { GanttChart } from "./components/gantt/GanttChart";
|
||||
export { TaskModal } from "./components/kanban/TaskModal";
|
||||
export { KanbanBoard } from "./components/kanban/KanbanBoard";
|
||||
|
||||
// Hooks
|
||||
// export { useChat } from "./hooks/useChat";
|
||||
// export { useChatUnread } from "./hooks/useChatUnread";
|
||||
export { useChat } from "./hooks/useChat";
|
||||
export { useChatUnread } from "./hooks/useChatUnread";
|
||||
|
||||
// Types
|
||||
// export type { TabloMember } from "./components/kanban/types";
|
||||
export type { TabloMember } from "./components/kanban/types";
|
||||
|
|
|
|||
1
packages/tablo-views/src/vite-env.d.ts
vendored
Normal file
1
packages/tablo-views/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
|
@ -15,6 +15,7 @@
|
|||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"types": ["vite/client"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@xtablo/ui": ["../ui/src"],
|
||||
|
|
@ -23,5 +24,5 @@
|
|||
"@xtablo/shared/*": ["../shared/src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src", "src/vite-env.d.ts"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -642,6 +642,61 @@ importers:
|
|||
specifier: ^5.7.0
|
||||
version: 5.9.3
|
||||
|
||||
packages/tablo-views:
|
||||
dependencies:
|
||||
'@tanstack/react-query':
|
||||
specifier: ^5.69.0
|
||||
version: 5.90.5(react@19.0.0)
|
||||
'@xtablo/chat-ui':
|
||||
specifier: workspace:*
|
||||
version: link:../chat-ui
|
||||
'@xtablo/shared':
|
||||
specifier: workspace:*
|
||||
version: link:../shared
|
||||
'@xtablo/shared-types':
|
||||
specifier: workspace:*
|
||||
version: link:../shared-types
|
||||
'@xtablo/ui':
|
||||
specifier: workspace:*
|
||||
version: link:../ui
|
||||
date-fns:
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0
|
||||
lucide-react:
|
||||
specifier: ^0.460.0
|
||||
version: 0.460.0(react@19.0.0)
|
||||
react:
|
||||
specifier: 19.0.0
|
||||
version: 19.0.0
|
||||
react-dom:
|
||||
specifier: 19.0.0
|
||||
version: 19.0.0(react@19.0.0)
|
||||
react-i18next:
|
||||
specifier: ^16.2.0
|
||||
version: 16.2.0(i18next@25.6.0(typescript@5.9.3))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3)
|
||||
react-router-dom:
|
||||
specifier: ^7.9.4
|
||||
version: 7.9.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
tailwind-merge:
|
||||
specifier: ^3.0.2
|
||||
version: 3.3.1
|
||||
devDependencies:
|
||||
'@biomejs/biome':
|
||||
specifier: 2.2.5
|
||||
version: 2.2.5
|
||||
'@types/react':
|
||||
specifier: 19.0.10
|
||||
version: 19.0.10
|
||||
'@types/react-dom':
|
||||
specifier: 19.0.4
|
||||
version: 19.0.4(@types/react@19.0.10)
|
||||
typescript:
|
||||
specifier: ^5.7.0
|
||||
version: 5.9.3
|
||||
vite:
|
||||
specifier: ^6.2.2
|
||||
version: 6.4.1(@types/node@22.18.12)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.1)(tsx@4.20.6)
|
||||
|
||||
packages/ui:
|
||||
dependencies:
|
||||
'@floating-ui/react':
|
||||
|
|
|
|||
Loading…
Reference in a new issue