Fix CircleCI docker node pull

This commit is contained in:
Arthur Belleville 2026-03-08 21:28:44 +01:00
parent 03e426dd23
commit 992b846a85
No known key found for this signature in database
34 changed files with 1874 additions and 2368 deletions

View file

@ -14,7 +14,7 @@ jobs:
executor:
name: node/default
resource_class: small
tag: '16'
tag: 'lts'
steps:
- checkout
- node/install-packages:
@ -22,15 +22,12 @@ jobs:
- run:
name: Run linting
command: pnpm run lint
- run:
name: Check formatting
command: pnpm run format --check || echo "Format check complete"
test-typecheck:
executor:
name: node/default
resource_class: small
tag: '16'
tag: 'lts'
steps:
- checkout
- node/install-packages:
@ -44,7 +41,7 @@ jobs:
executor:
name: node/default
resource_class: medium
tag: '16'
tag: 'lts'
steps:
- checkout
- node/install-packages:
@ -52,44 +49,27 @@ jobs:
cache-path: ~/.pnpm-store
- run:
name: Run unit tests
command: pnpm run test
- store_test_results:
path: apps/main/coverage
- store_artifacts:
path: apps/main/coverage
destination: coverage
command: pnpm --filter @xtablo/main run test
test-api:
executor:
name: node/default
tag: '16'
tag: 'lts'
resource_class: small
steps:
- checkout
- restore_cache:
name: Restore npm API Cache
keys:
- npm-api-{{ checksum "api/package-lock.json" }}
- node/install-packages:
pkg-manager: pnpm
cache-path: ~/.pnpm-store
- run:
name: Install API dependencies
name: Run API checks
command: |
cd api
npm ci
- save_cache:
name: Save npm API Cache
key: npm-api-{{ checksum "api/package-lock.json" }}
paths:
- api/node_modules
- run:
name: Lint API
command: |
cd api
npm run lint
- run:
name: Run API tests
command: |
cd api
npm run test
if [ "${RUN_API_INTEGRATION_TESTS:-0}" = "1" ]; then
pnpm --filter @xtablo/api run test
else
echo "Skipping API integration tests (set RUN_API_INTEGRATION_TESTS=1 to enable)."
pnpm --filter @xtablo/api run build
fi
# ============================================
# BUILD PHASE
@ -123,8 +103,6 @@ jobs:
paths:
- apps/main/dist
- apps/external/dist
- packages/ui/dist
- packages/shared/dist
- store_artifacts:
path: apps/main/dist
destination: main-app-<< parameters.environment >>
@ -138,26 +116,18 @@ jobs:
resource_class: small
steps:
- checkout
- restore_cache:
name: Restore npm API Cache
keys:
- npm-api-{{ checksum "api/package-lock.json" }}
- run:
name: Install API dependencies
command: |
cd api
npm ci
- node/install-packages:
pkg-manager: pnpm
cache-path: ~/.pnpm-store
- run:
name: Build API
command: |
cd api
npm run build
command: pnpm --filter @xtablo/api run build
- persist_to_workspace:
root: .
paths:
- api/dist
- apps/api/dist
- store_artifacts:
path: api/dist
path: apps/api/dist
destination: api
# ============================================
@ -174,9 +144,7 @@ jobs:
at: .
- run:
name: Build API Docker image
command: |
cd api
docker build -t xtablo-api:${CIRCLE_SHA1} -t xtablo-api:latest .
command: docker build -f apps/api/Dockerfile -t xtablo-api:${CIRCLE_SHA1} -t xtablo-api:latest .
- run:
name: Save Docker image
command: |

View file

@ -6,8 +6,8 @@ import {
PutObjectCommand,
S3Client,
} from "@aws-sdk/client-s3";
import type { TabloFoldersMetadata } from "@xtablo/shared-types";
import { sdkStreamMixin } from "@smithy/util-stream";
import type { TabloFoldersMetadata } from "@xtablo/shared-types";
import { mockClient } from "aws-sdk-client-mock";
import { testClient } from "hono/testing";
import { Readable } from "stream";
@ -664,12 +664,7 @@ describe("TabloData Endpoint", () => {
});
it("should return 400 if folder name is empty string", async () => {
const res = await createFolderRequest(
ownerUser,
client,
"test_tablo_owner_private",
" "
);
const res = await createFolderRequest(ownerUser, client, "test_tablo_owner_private", " ");
expect(res.status).toBe(400);
const data = await res.json();
@ -1110,9 +1105,7 @@ describe("TabloData Endpoint", () => {
});
});
describe(
"DELETE /tablo-data/:tabloId/file/:path - Delete File with Nested Path (Admin or Uploader)",
() => {
describe("DELETE /tablo-data/:tabloId/file/:path - Delete File with Nested Path (Admin or Uploader)", () => {
it("should allow admin to delete file with nested path", async () => {
const res = await deleteNestedFileRequest(
ownerUser,

View file

@ -230,9 +230,7 @@ const deleteTabloFile = (middlewareManager: ReturnType<typeof MiddlewareManager.
);
const uploadedBy =
headResponse.Metadata?.["uploaded-by"] ??
headResponse.Metadata?.uploaded_by ??
null;
headResponse.Metadata?.["uploaded-by"] ?? headResponse.Metadata?.uploaded_by ?? null;
if (uploadedBy !== user.id) {
return c.json({ error: "You can only delete files you uploaded" }, 403);

View file

@ -40,20 +40,20 @@ export function ActionCard({
: isPrimary
? "bg-primary text-white hover:shadow-lg"
: "bg-white dark:bg-gray-800 border border-[#EAECF0] dark:border-gray-700 hover:shadow-md",
className,
className
)}
>
<div className="flex items-center gap-3">
<div
className={cn(
"w-10 h-10 rounded-[8px] flex items-center justify-center flex-shrink-0",
isActive ? "bg-white/20" : "bg-[#F4F3FF] dark:bg-purple-900/20",
isActive ? "bg-white/20" : "bg-[#F4F3FF] dark:bg-purple-900/20"
)}
>
<span
className={cn(
"w-6 h-6 flex items-center justify-center",
isActive ? "text-white" : "text-[#7F56D9]",
isActive ? "text-white" : "text-[#7F56D9]"
)}
>
{icon}
@ -65,7 +65,7 @@ export function ActionCard({
<span
className={cn(
"font-semibold text-lg leading-tight",
isActive ? "text-white" : "text-gray-900 dark:text-gray-100",
isActive ? "text-white" : "text-gray-900 dark:text-gray-100"
)}
>
{label}
@ -79,7 +79,7 @@ export function ActionCard({
<p
className={cn(
"text-sm mt-0.5",
isActive ? "text-purple-100" : "text-gray-500 dark:text-gray-400",
isActive ? "text-purple-100" : "text-gray-500 dark:text-gray-400"
)}
>
{description}

View file

@ -13,28 +13,21 @@ type TaskWithTablo = KanbanTask & {
tablos: { id: string; name: string; color: string | null } | null;
};
const STATUS_BADGE: Record<
TaskStatus,
{ className: string; labelKey: string }
> = {
const STATUS_BADGE: Record<TaskStatus, { className: string; labelKey: string }> = {
todo: {
className:
"bg-blue-50 text-blue-600 dark:bg-blue-950/30 dark:text-blue-400",
className: "bg-blue-50 text-blue-600 dark:bg-blue-950/30 dark:text-blue-400",
labelKey: "dashboard.taskList.status.todo",
},
in_progress: {
className:
"bg-yellow-50 text-yellow-600 dark:bg-yellow-950/30 dark:text-yellow-400",
className: "bg-yellow-50 text-yellow-600 dark:bg-yellow-950/30 dark:text-yellow-400",
labelKey: "dashboard.taskList.status.inProgress",
},
in_review: {
className:
"bg-purple-50 text-purple-600 dark:bg-purple-950/30 dark:text-purple-400",
className: "bg-purple-50 text-purple-600 dark:bg-purple-950/30 dark:text-purple-400",
labelKey: "dashboard.taskList.status.inReview",
},
done: {
className:
"bg-green-50 text-green-600 dark:bg-green-950/30 dark:text-green-400",
className: "bg-green-50 text-green-600 dark:bg-green-950/30 dark:text-green-400",
labelKey: "dashboard.taskList.status.done",
},
};
@ -76,7 +69,7 @@ function TaskRow({
"w-6 h-6 rounded-full border-2 flex items-center justify-center shrink-0",
isDone
? "bg-purple-600 border-purple-600"
: "border-gray-300 hover:border-purple-400 dark:border-gray-600 dark:hover:border-purple-500",
: "border-gray-300 hover:border-purple-400 dark:border-gray-600 dark:hover:border-purple-500"
)}
onClick={(e) => {
e.stopPropagation();
@ -92,7 +85,7 @@ function TaskRow({
"text-sm font-medium truncate",
isDone
? "line-through text-gray-400 dark:text-gray-500"
: "text-gray-900 dark:text-gray-100",
: "text-gray-900 dark:text-gray-100"
)}
>
{task.title}
@ -105,7 +98,7 @@ function TaskRow({
<div
className={cn(
"w-6 h-6 rounded-lg flex items-center justify-center text-xs shrink-0",
task.tablos.color || "bg-gray-400",
task.tablos.color || "bg-gray-400"
)}
>
<span className="text-white font-bold text-[10px]">
@ -128,7 +121,7 @@ function TaskRow({
<span
className={cn(
"px-3 py-1 rounded-full text-xs font-medium whitespace-nowrap",
badge.className,
badge.className
)}
>
{t(badge.labelKey)}
@ -146,14 +139,10 @@ export function DashboardTaskList() {
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
// Filter to tasks assigned to the current user, limited to recent ones
const myTasks =
allTasks
?.filter((task) => task.assignee_id === user.id)
.slice(0, 7) ?? [];
const myTasks = allTasks?.filter((task) => task.assignee_id === user.id).slice(0, 7) ?? [];
const handleToggleDone = (task: TaskWithTablo) => {
const newStatus: TaskStatus =
task.status === "done" ? "todo" : "done";
const newStatus: TaskStatus = task.status === "done" ? "todo" : "done";
updateTask.mutate({ id: task.id, status: newStatus });
};
@ -177,11 +166,7 @@ export function DashboardTaskList() {
<div className="overflow-x-auto">
<div className="min-w-[600px]">
{myTasks.map((task) => (
<TaskRow
key={task.id}
task={task}
onToggleDone={handleToggleDone}
/>
<TaskRow key={task.id} task={task} onToggleDone={handleToggleDone} />
))}
</div>
</div>

View file

@ -41,7 +41,7 @@ export const ExceptionModal = ({
}) => {
const { t } = useTranslation("components");
const form = useForm<z.infer<typeof formSchema>>({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// biome-ignore lint/suspicious/noExplicitAny: zodResolver typing is incompatible with current zod type in this package.
resolver: zodResolver(formSchema as any),
defaultValues: {
exceptionType: "day",

View file

@ -64,12 +64,7 @@ export interface ProjectCardProps {
className?: string;
}
export function ProjectCard({
tablo,
onClick,
onMenuClick,
className,
}: ProjectCardProps) {
export function ProjectCard({ tablo, onClick, onMenuClick, className }: ProjectCardProps) {
const { t } = useTranslation("pages");
const statusConfig = useStatusConfig(tablo.status);
const progress = getProgressFromStatus(tablo.status);
@ -84,7 +79,7 @@ export function ProjectCard({
<div
className={cn(
"bg-white dark:bg-gray-800 rounded-2xl p-4 border border-[#EAECF0] dark:border-gray-700 hover:shadow-md transition-shadow cursor-pointer",
className,
className
)}
onClick={() => onClick?.(tablo.id)}
>
@ -93,7 +88,7 @@ export function ProjectCard({
<span
className={cn(
"px-3 py-1 rounded-full text-xs font-medium border",
statusConfig.badgeClass,
statusConfig.badgeClass
)}
>
{statusConfig.label}
@ -114,15 +109,11 @@ export function ProjectCard({
<div
className={cn(
"w-12 h-12 rounded-xl flex items-center justify-center shrink-0 overflow-hidden",
!tablo.image && (tablo.color || "bg-gray-400"),
!tablo.image && (tablo.color || "bg-gray-400")
)}
>
{tablo.image ? (
<img
src={tablo.image}
alt={tablo.name}
className="w-full h-full object-cover"
/>
<img src={tablo.image} alt={tablo.name} className="w-full h-full object-cover" />
) : (
<span className="text-white font-bold text-lg">
{tablo.name.charAt(0).toUpperCase()}
@ -143,19 +134,12 @@ export function ProjectCard({
{/* Progress */}
<div className="mb-3">
<div className="flex items-center justify-between text-sm mb-2">
<span className="text-gray-600 dark:text-gray-400">
{t("tablo.card.progress")}:
</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{progress}%
</span>
<span className="text-gray-600 dark:text-gray-400">{t("tablo.card.progress")}:</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">{progress}%</span>
</div>
<div className="w-full bg-gray-100 dark:bg-gray-700 rounded-full h-2">
<div
className={cn(
"h-2 rounded-full transition-all",
statusConfig.progressColor,
)}
className={cn("h-2 rounded-full transition-all", statusConfig.progressColor)}
style={{ width: `${progress}%` }}
/>
</div>

View file

@ -1,12 +1,12 @@
import { 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";
import { TypographyH3, TypographyMuted } from "@xtablo/ui/components/typography";
import { useTranslation } from "react-i18next";
interface TabloEventsSectionProps {
tablo: UserTablo;
@ -65,7 +65,10 @@ export const TabloEventsSection = ({ tablo, isAdmin }: TabloEventsSectionProps)
{t("tablo:events.description")}
</TypographyMuted>
{!isReadOnly && (
<Button onClick={handleCreateEvent} className="flex items-center gap-2 mt-4 bg-[#804EEC] hover:bg-[#6f3fd4] text-white">
<Button
onClick={handleCreateEvent}
className="flex items-center gap-2 mt-4 bg-[#804EEC] hover:bg-[#6f3fd4] text-white"
>
<Plus className="w-4 h-4" />
{t("tablo:events.createEvent")}
</Button>
@ -172,7 +175,10 @@ export const TabloEventsSection = ({ tablo, isAdmin }: TabloEventsSectionProps)
Aucun événement à venir pour ce tablo
</p>
{!isReadOnly && (
<Button onClick={handleCreateEvent} className="mt-4 bg-[#804EEC] hover:bg-[#6f3fd4] text-white">
<Button
onClick={handleCreateEvent}
className="mt-4 bg-[#804EEC] hover:bg-[#6f3fd4] text-white"
>
<Plus className="w-4 h-4 mr-2" />
Créer le premier événement
</Button>

View file

@ -455,7 +455,11 @@ const FolderSection = ({
export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) => {
const currentUser = useUser();
const { data: fileData, isLoading: filesLoading, error: filesError } = useTabloFileNames(tablo.id);
const {
data: fileData,
isLoading: filesLoading,
error: filesError,
} = useTabloFileNames(tablo.id);
const {
data: foldersData,
isLoading: foldersLoading,

View file

@ -13,12 +13,12 @@ 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 { ClickOutside } from "./ClickOutside";
import { ImageColorPicker } from "./ImageColorPicker";
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 TabloHeaderActionsProps {
tablo: UserTablo;
@ -303,7 +303,8 @@ export const TabloHeaderActions = ({ tablo, isAdmin }: TabloHeaderActionsProps)
<div className="space-y-2 max-h-48 overflow-y-auto">
{filteredMembers.map((member, index) => {
const avatarUrl =
member.avatar_url ?? (member.id === currentUser.id ? currentUser.avatar_url : null);
member.avatar_url ??
(member.id === currentUser.id ? currentUser.avatar_url : null);
return (
<div
key={index}

View file

@ -2,10 +2,7 @@ import { UserTablo } from "@xtablo/shared/types/tablos.types";
import { Button } from "@xtablo/ui/components/button";
import { Users } from "lucide-react";
import { useState } from "react";
import {
useCancelTabloInvite,
usePendingTabloInvitesByTablo,
} from "src/hooks/tablo_invites";
import { useCancelTabloInvite, usePendingTabloInvitesByTablo } from "src/hooks/tablo_invites";
import { useInviteUser } from "../hooks/invite";
import { useTabloMembers } from "../hooks/tablos";
import { useUser } from "../providers/UserStoreProvider";
@ -19,8 +16,7 @@ export const TabloMembersSection = ({ tablo, isAdmin }: TabloMembersSectionProps
const currentUser = useUser();
const { data: members } = useTabloMembers(tablo.id);
const { data: pendingInvites } = usePendingTabloInvitesByTablo(tablo.id);
const { mutate: cancelInvite, isPending: isCancellingInvite } =
useCancelTabloInvite();
const { mutate: cancelInvite, isPending: isCancellingInvite } = useCancelTabloInvite();
const [inviteEmail, setInviteEmail] = useState("");
const { mutate: inviteUser, isPending: isInvitingUser } = useInviteUser();
@ -122,9 +118,7 @@ export const TabloMembersSection = ({ tablo, isAdmin }: TabloMembersSectionProps
<Button
size="sm"
variant="ghost"
onClick={() =>
cancelInvite({ tabloId: tablo.id, inviteId: invite.id })
}
onClick={() => cancelInvite({ tabloId: tablo.id, inviteId: invite.id })}
disabled={isCancellingInvite}
>
Retirer

View file

@ -1,6 +1,7 @@
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 { 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";
@ -13,7 +14,6 @@ import {
import { KanbanBoard } from "./kanban/KanbanBoard";
import { TaskModal } from "./kanban/TaskModal";
import { TabloHeaderActions } from "./TabloHeaderActions";
import { TypographyH3, TypographyMuted } from "@xtablo/ui/components/typography";
interface TabloTasksSectionProps {
tablo: UserTablo;

View file

@ -1,4 +1,5 @@
import type { Database } from "@xtablo/shared-types";
import { Avatar, AvatarFallback, AvatarImage } from "@xtablo/ui/components/avatar";
import { Badge } from "@xtablo/ui/components/badge";
import { Button } from "@xtablo/ui/components/button";
import {
@ -8,15 +9,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@xtablo/ui/components/dropdown-menu";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@xtablo/ui/components/avatar";
import {
TypographyMuted,
TypographySmall,
} from "@xtablo/ui/components/typography";
import { TypographyMuted, TypographySmall } from "@xtablo/ui/components/typography";
import {
BellIcon,
CalendarCheckIcon,
@ -36,10 +29,7 @@ import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Link, useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { useLogout } from "../hooks/auth";
import {
useNotifications,
useNotificationsSubscription,
} from "../hooks/notifications";
import { useNotifications, useNotificationsSubscription } from "../hooks/notifications";
import {
useAcceptTabloInvite,
useCancelTabloInvite,
@ -171,8 +161,7 @@ function NotificationItem({
function NotificationDropdown() {
const { t } = useTranslation("navigation");
const { notifications, unreadCount, isLoading, markAsRead, markAllAsRead } =
useNotifications();
const { notifications, unreadCount, isLoading, markAsRead, markAllAsRead } = useNotifications();
const { setupSubscription } = useNotificationsSubscription();
useEffect(() => {
@ -209,9 +198,7 @@ function NotificationDropdown() {
{t("notifications.title", "Notifications")}
</TypographySmall>
{unreadCount > 0 && (
<Badge className="bg-red-500 text-white text-xs">
{unreadCount}
</Badge>
<Badge className="bg-red-500 text-white text-xs">{unreadCount}</Badge>
)}
</div>
{unreadCount > 0 && (
@ -250,10 +237,7 @@ function NotificationDropdown() {
<div className="divide-y divide-gray-100">
{notifications.map((notification) => (
<div key={notification.id} className="group">
<NotificationItem
notification={notification}
onMarkAsRead={markAsRead}
/>
<NotificationItem notification={notification} onMarkAsRead={markAsRead} />
</div>
))}
</div>
@ -298,12 +282,16 @@ function TabloInvitesDropdown() {
sideOffset={8}
>
<div className="px-4 py-3 border-b border-gray-100">
<TypographySmall className="font-semibold text-gray-900">{t("invites.title")}</TypographySmall>
<TypographySmall className="font-semibold text-gray-900">
{t("invites.title")}
</TypographySmall>
</div>
<div className="max-h-[340px] overflow-y-auto divide-y divide-gray-100">
{invites.map((invite) => (
<div key={invite.id} className="px-4 py-3">
<TypographySmall className="font-medium text-gray-900">{invite.tablo_name}</TypographySmall>
<TypographySmall className="font-medium text-gray-900">
{invite.tablo_name}
</TypographySmall>
<div className="mt-3 flex items-center gap-2">
<Button
variant="outline"
@ -367,10 +355,7 @@ function ProfileDropdown() {
>
<div className="flex gap-2 p-2">
<Avatar className="size-8">
<AvatarImage
src={user.avatar_url ?? undefined}
alt={user.name ?? "User avatar"}
/>
<AvatarImage src={user.avatar_url ?? undefined} alt={user.name ?? "User avatar"} />
<AvatarFallback className="bg-[#B8EAFF] text-gray-800 font-medium">
{user.name?.charAt(0).toUpperCase()}
</AvatarFallback>

View file

@ -42,10 +42,7 @@ interface GanttChartProps {
// ─── Helpers ─────────────────────────────────────────────────────────────────
const STATUS_STYLES: Record<
string,
{ bg: string; border: string; dot: string; label: string }
> = {
const STATUS_STYLES: Record<string, { bg: string; border: string; dot: string; label: string }> = {
todo: {
bg: "bg-[#EFF8FF]",
border: "border-l-[#3B82F6]",
@ -79,12 +76,7 @@ const STATUS_TEXT_COLORS: Record<string, string> = {
done: "text-[#16B364]",
};
const ROADMAP_TASK_STATUSES: TaskStatus[] = [
"todo",
"in_progress",
"in_review",
"done",
];
const ROADMAP_TASK_STATUSES: TaskStatus[] = ["todo", "in_progress", "in_review", "done"];
function getTabloIcon(color: string | null | undefined) {
switch (color) {
@ -137,9 +129,7 @@ function isSameDay(a: Date, b: Date): boolean {
}
function formatShortDay(date: Date): string {
return date
.toLocaleDateString("fr-FR", { weekday: "short" })
.replace(".", "");
return date.toLocaleDateString("fr-FR", { weekday: "short" }).replace(".", "");
}
function formatDateRange(start: Date, end: Date): string {
@ -161,12 +151,7 @@ const CARD_TOP_OFFSET = 20;
// ─── Component ───────────────────────────────────────────────────────────────
export function GanttChart({
tasks,
isLoading,
onDateClick,
onTaskStatusChange,
}: GanttChartProps) {
export function GanttChart({ tasks, isLoading, onDateClick, onTaskStatusChange }: GanttChartProps) {
const [weekOffset, setWeekOffset] = useState(0);
const [viewMode, setViewMode] = useState<ViewMode>("weekly");
const containerRef = useRef<HTMLDivElement>(null);
@ -198,18 +183,12 @@ export function GanttChart({
return d;
}, []);
const periodStart = useMemo(
() => addDays(getMonday(today), weekOffset * 7),
[today, weekOffset],
);
const periodEnd = useMemo(
() => addDays(periodStart, numDays - 1),
[periodStart, numDays],
);
const periodStart = useMemo(() => addDays(getMonday(today), weekOffset * 7), [today, weekOffset]);
const periodEnd = useMemo(() => addDays(periodStart, numDays - 1), [periodStart, numDays]);
const days = useMemo(
() => Array.from({ length: numDays }, (_, i) => addDays(periodStart, i)),
[periodStart, numDays],
[periodStart, numDays]
);
// Filter tasks with due_date in this period
@ -260,10 +239,7 @@ export function GanttChart({
// Compute chart height
const maxRow = positionedTasks.reduce((max, pt) => Math.max(max, pt.row), 0);
const chartHeight = Math.max(
400,
(maxRow + 1) * (cardHeight + CARD_GAP) + CARD_TOP_OFFSET + 20,
);
const chartHeight = Math.max(400, (maxRow + 1) * (cardHeight + CARD_GAP) + CARD_TOP_OFFSET + 20);
// Today indicator position
const todayIndex = days.findIndex((d) => isSameDay(d, today));
@ -354,7 +330,7 @@ export function GanttChart({
<span
className={twMerge(
"text-sm font-medium",
isToday ? "text-primary" : "text-muted-foreground",
isToday ? "text-primary" : "text-muted-foreground"
)}
>
{formatShortDay(day)} {day.getDate()}
@ -384,10 +360,7 @@ export function GanttChart({
{days.map((_, i) => (
<div
key={i}
className={twMerge(
"border-border",
i < numDays - 1 ? "border-r" : "",
)}
className={twMerge("border-border", i < numDays - 1 ? "border-r" : "")}
style={{ width: colWidth }}
/>
))}
@ -412,27 +385,19 @@ export function GanttChart({
{/* Task cards */}
{positionedTasks.map((pt) => {
const status =
STATUS_STYLES[pt.task.status ?? "todo"] ?? STATUS_STYLES.todo;
const status = STATUS_STYLES[pt.task.status ?? "todo"] ?? STATUS_STYLES.todo;
const textColor =
STATUS_TEXT_COLORS[pt.task.status ?? "todo"] ??
STATUS_TEXT_COLORS.todo;
const TabloIcon = pt.task.tablos
? getTabloIcon(pt.task.tablos.color)
: null;
STATUS_TEXT_COLORS[pt.task.status ?? "todo"] ?? STATUS_TEXT_COLORS.todo;
const TabloIcon = pt.task.tablos ? getTabloIcon(pt.task.tablos.color) : null;
const isCompact = viewMode === "biweekly";
const taskCardContent = (
<>
{/* Status badge */}
<div className="flex items-center gap-1.5 bg-white w-fit px-2 py-0.5 rounded-full shadow-sm">
<span
className={twMerge("w-2 h-2 rounded-full", status.dot)}
/>
<span className={twMerge("w-2 h-2 rounded-full", status.dot)} />
{!isCompact && (
<span
className={twMerge("text-xs font-medium", textColor)}
>
<span className={twMerge("text-xs font-medium", textColor)}>
{status.label}
</span>
)}
@ -442,7 +407,7 @@ export function GanttChart({
<h3
className={twMerge(
"font-semibold text-gray-900 leading-tight line-clamp-1",
isCompact ? "mt-1 text-xs" : "mt-2 text-sm",
isCompact ? "mt-1 text-xs" : "mt-2 text-sm"
)}
>
{pt.task.title}
@ -463,7 +428,7 @@ export function GanttChart({
<div
className={twMerge(
"w-5 h-5 rounded-md flex items-center justify-center",
pt.task.tablos.color || "bg-gray-400",
pt.task.tablos.color || "bg-gray-400"
)}
>
<TabloIcon className="w-3 h-3 text-white" />
@ -484,7 +449,7 @@ export function GanttChart({
"absolute z-30 rounded-lg border-l-4 shadow-sm transition-all hover:shadow-md overflow-hidden text-left cursor-default",
isCompact ? "p-2" : "p-3",
status.bg,
status.border,
status.border
)}
style={{
left: pt.left,
@ -509,7 +474,7 @@ export function GanttChart({
isCompact ? "p-2" : "p-3",
status.bg,
status.border,
"cursor-pointer",
"cursor-pointer"
)}
style={{
left: pt.left,
@ -528,15 +493,13 @@ export function GanttChart({
<DropdownMenuItem
key={nextStatus}
disabled={isCurrent}
onClick={() =>
onTaskStatusChange(pt.task.id, nextStatus)
}
onClick={() => onTaskStatusChange(pt.task.id, nextStatus)}
className="gap-2"
>
<span
className={twMerge(
"w-2 h-2 rounded-full",
STATUS_STYLES[nextStatus]?.dot ?? "bg-gray-400",
STATUS_STYLES[nextStatus]?.dot ?? "bg-gray-400"
)}
/>
<span>

View file

@ -16,12 +16,7 @@ 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 { useCreateTask, useTabloEtapes, useTask, useUpdateTask } from "../../hooks/tasks";
import type { TabloMember } from "./types";
interface TaskModalProps {
@ -56,26 +51,20 @@ export const TaskModal = ({
const [etapeId, setEtapeId] = useState<string>("none");
const [dueDate, setDueDate] = useState<Date | undefined>(undefined);
const [selectedTabloId, setSelectedTabloId] = useState<string>(
initialTabloId || tablos?.[0]?.id || "",
initialTabloId || tablos?.[0]?.id || ""
);
// Determine which tablo to use for fetching data
const tabloIdForFetch = allowTabloSelection
? selectedTabloId
: initialTabloId || "";
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,
);
const { data: fetchedEtapes = [] } = useTabloEtapes(tabloIdForFetch || undefined);
// Use provided or fetched data
const members = providedMembers || fetchedMembers;
const etapes = providedEtapes || fetchedEtapes;
const currentTabloId = allowTabloSelection
? selectedTabloId
: initialTabloId || "";
const currentTabloId = allowTabloSelection ? selectedTabloId : initialTabloId || "";
useEffect(() => {
if (task) {
@ -174,10 +163,7 @@ export const TaskModal = ({
{allowTabloSelection && !taskId && tablos && tablos.length > 0 && (
<div className="space-y-2">
<Label htmlFor="tablo">Tablo *</Label>
<Select
value={selectedTabloId}
onValueChange={setSelectedTabloId}
>
<Select value={selectedTabloId} onValueChange={setSelectedTabloId}>
<SelectTrigger id="tablo" className="w-full">
<SelectValue placeholder="Sélectionner un tablo" />
</SelectTrigger>
@ -225,11 +211,7 @@ export const TaskModal = ({
{/* Due Date */}
<div className="space-y-2">
<Label>Échéance</Label>
<DatePicker
value={dueDate}
onChange={setDueDate}
placeholder="Choisir une date"
/>
<DatePicker value={dueDate} onChange={setDueDate} placeholder="Choisir une date" />
</div>
{/* Assignee */}

View file

@ -92,10 +92,7 @@ export const useAllTasks = () => {
};
// Fetch all tasks for a specific tablo
export const useTasksByTablo = (
tabloId: string | undefined,
options?: { assigneeId?: string }
) => {
export const useTasksByTablo = (tabloId: string | undefined, options?: { assigneeId?: string }) => {
const assigneeId = options?.assigneeId;
return useQuery({

View file

@ -1,4 +1,5 @@
import { RouteObject } from "react-router-dom";
import { EventsPage } from "src/pages/events";
import { AuthenticationGateway } from "../components/AuthenticationGateway";
import { EventModal } from "../components/EventModal";
import { Layout } from "../components/Layout";
@ -8,6 +9,7 @@ import { ChantiersPage } from "../pages/chantiers";
import { ChatPage } from "../pages/chat";
import { ConfirmEmailPage } from "../pages/confirm-email";
import { FeedbackPage } from "../pages/feedback";
import { FilesPage } from "../pages/files";
import { JoinPage } from "../pages/join";
import { LegalNoticePage } from "../pages/legal-notice";
import { LoginPage } from "../pages/login";
@ -25,10 +27,8 @@ import { TabloPage } from "../pages/tablo";
import { TabloDetailsPage } from "../pages/tablo-details";
import { TablosPage } from "../pages/tablos";
import { TasksPage } from "../pages/tasks";
import { FilesPage } from "../pages/files";
import { UpdatePasswordPage } from "../pages/update-password";
import ChatProvider from "../providers/ChatProvider";
import { EventsPage } from "src/pages/events";
export const routes: RouteObject[] = [
// Protected routes
@ -98,10 +98,7 @@ export const routes: RouteObject[] = [
{
path: "events",
element: <EventsPage />,
children: [
{ index: true },
{ path: "create", element: <EventModal mode="create" /> },
],
children: [{ index: true }, { path: "create", element: <EventModal mode="create" /> }],
},
{
path: "tasks",

View file

@ -1,5 +1,6 @@
import { datadogRum } from "@datadog/browser-rum";
import { reactPlugin } from "@datadog/browser-rum-react";
// import { getCookieConsent } from "../hooks/useCookieConsent";
// Check if user has consented to analytics cookies before initializing

File diff suppressed because it is too large Load diff

View file

@ -28,13 +28,13 @@ import { useTranslation } from "react-i18next";
import { EventTypeCard } from "../components/EventTypeCard";
import { EventTypeModal } from "../components/EventTypeModal";
import { ExceptionModal } from "../components/ExceptionModal";
import { EventTypeConfig, useEventTypes } from "../hooks/event-types";
import {
DEFAULT_AVAILABILITIES,
Exception,
useAvailabilities,
WeeklyAvailability,
} from "../hooks/availabilities";
import { EventTypeConfig, useEventTypes } from "../hooks/event-types";
import { useIsReadOnlyUser } from "../providers/UserStoreProvider";
const DAYS_OF_WEEK = [0, 1, 2, 3, 4, 5, 6];
@ -238,20 +238,20 @@ export function AvailabilitiesPage() {
<SaveIcon /> {t("availabilities:actions.save")}
</Button>
)}
<Button
size="sm"
onClick={() => {
updateAvailabilities({
updatedAvailabilities: DEFAULT_AVAILABILITIES,
});
}}
className="py-1 border border-[#804EEC]/35 text-[#804EEC] bg-white hover:bg-[#804EEC]/10"
>
{t("availabilities:actions.businessHours")}
</Button>
<Button
size="sm"
variant="outline"
<Button
size="sm"
onClick={() => {
updateAvailabilities({
updatedAvailabilities: DEFAULT_AVAILABILITIES,
});
}}
className="py-1 border border-[#804EEC]/35 text-[#804EEC] bg-white hover:bg-[#804EEC]/10"
>
{t("availabilities:actions.businessHours")}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
const newAvailabilities: WeeklyAvailability = {};
DAYS_OF_WEEK.forEach((day) => {
@ -479,9 +479,7 @@ export function AvailabilitiesPage() {
<section className="rounded-2xl border border-[#804EEC]/25 bg-card p-6">
<div className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 className="text-xl font-semibold">
{t("availabilities:callTypes.title")}
</h3>
<h3 className="text-xl font-semibold">{t("availabilities:callTypes.title")}</h3>
<Text className="text-muted-foreground">
{t("availabilities:callTypes.description")}
</Text>

View file

@ -41,47 +41,47 @@ export function ChatPage() {
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Discussions</h1>
</div>
<div className="flex flex-1 overflow-hidden">
<div
className={`border-r border-gray-200 dark:border-gray-600/50 bg-white dark:bg-gray-700/40 transition-all duration-300 ease-in-out overflow-hidden ${
isChannelListExpanded ? "w-80" : "w-0"
}`}
>
<ChannelList
filters={filters}
setActiveChannelOnMount={isChannelInUrl ? false : true}
Preview={({
displayTitle,
channel,
activeChannel,
setActiveChannel,
unread,
latestMessagePreview,
}) => (
<ChannelPreview
displayTitle={displayTitle}
channel={channel}
tablo={tablos?.find((t) => t.id === channel.id) ?? null}
activeChannel={activeChannel}
setActiveChannel={setActiveChannel}
unreadCount={unread}
latestMessagePreview={latestMessagePreview}
/>
)}
/>
</div>
<div className="flex-1 bg-white dark:bg-gray-700/40">
<Channel channel={channel}>
<Window>
<CustomChannelHeader
tablos={tablos ?? []}
onToggleChannelList={toggleChannelList}
isChannelListExpanded={isChannelListExpanded}
/>
<MessageList />
<MessageInput />
</Window>
</Channel>
</div>
<div
className={`border-r border-gray-200 dark:border-gray-600/50 bg-white dark:bg-gray-700/40 transition-all duration-300 ease-in-out overflow-hidden ${
isChannelListExpanded ? "w-80" : "w-0"
}`}
>
<ChannelList
filters={filters}
setActiveChannelOnMount={isChannelInUrl ? false : true}
Preview={({
displayTitle,
channel,
activeChannel,
setActiveChannel,
unread,
latestMessagePreview,
}) => (
<ChannelPreview
displayTitle={displayTitle}
channel={channel}
tablo={tablos?.find((t) => t.id === channel.id) ?? null}
activeChannel={activeChannel}
setActiveChannel={setActiveChannel}
unreadCount={unread}
latestMessagePreview={latestMessagePreview}
/>
)}
/>
</div>
<div className="flex-1 bg-white dark:bg-gray-700/40">
<Channel channel={channel}>
<Window>
<CustomChannelHeader
tablos={tablos ?? []}
onToggleChannelList={toggleChannelList}
isChannelListExpanded={isChannelListExpanded}
/>
<MessageList />
<MessageInput />
</Window>
</Channel>
</div>
</div>
</div>
);

View file

@ -13,12 +13,7 @@ import {
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 { Calendar as CalendarIcon, ChevronLeft, ChevronRight, SearchIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Outlet, useNavigate } from "react-router-dom";
@ -230,285 +225,279 @@ export function EventsPage() {
</div>
<div className="space-y-6">
{/* Filters */}
<div className="bg-card rounded-lg shadow-sm border border-border p-6">
<div className="flex flex-col lg:flex-row gap-4 items-start lg:items-center">
{/* 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={t("pages:events.search")}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 h-10"
/>
</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={t("pages:events.search")}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 h-10"
/>
</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 tablos" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("pages:events.filters.allTablos")}</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"
>
{t(`pages:events.filters.${option.id}`)}
</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">
{t("pages:events.emptyState.title")}
</h3>
<p className="mt-1 text-sm text-muted-foreground">
{searchTerm || statusFilter !== "all"
? t("pages:events.emptyState.noResults")
: t("pages:events.emptyState.noEvents")}
</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>
{/* 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 tablos" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("pages:events.filters.allTablos")}</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"
)}
</div>
/>
{tablo.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{event.description && (
<Text className="text-muted-foreground line-clamp-2">
{event.description}
</Text>
{/* 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"
>
{t(`pages:events.filters.${option.id}`)}
</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">
{t("pages:events.emptyState.title")}
</h3>
<p className="mt-1 text-sm text-muted-foreground">
{searchTerm || statusFilter !== "all"
? t("pages:events.emptyState.noResults")
: t("pages:events.emptyState.noEvents")}
</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>
<div className="flex items-center space-x-2 ml-4">
<Button
variant="outline"
size="sm"
onClick={() => handleViewEvent(event)}
>
{t("common:buttons.details")}
</Button>
</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)}>
{t("common:buttons.details")}
</Button>
</div>
</div>
))}
</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>
{t("pages:events.pagination.showing", {
start: startIndex + 1,
end: Math.min(endIndex, totalItems),
total: totalItems,
})}
{/* 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>
{t("pages:events.pagination.showing", {
start: startIndex + 1,
end: Math.min(endIndex, totalItems),
total: totalItems,
})}
</span>
<div className="flex items-center space-x-2">
<span className="whitespace-nowrap">
{t("pages:events.pagination.itemsPerPage")}
</span>
<div className="flex items-center space-x-2">
<span className="whitespace-nowrap">
{t("pages:events.pagination.itemsPerPage")}
</span>
<Select
value={itemsPerPage.toString()}
onValueChange={(value) => setItemsPerPage(Number(value))}
<Select
value={itemsPerPage.toString()}
onValueChange={(value) => setItemsPerPage(Number(value))}
>
<SelectTrigger
className="min-w-16 h-8"
aria-label="Nombre d'éléments par page"
>
<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>
<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>
{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" />
{t("pages:events.pagination.previous")}
</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}
>
{t("pages:events.pagination.next")}
<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">
{t("pages:events.stats.found")}
{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" />
{t("pages:events.pagination.previous")}
</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}
>
{t("pages:events.pagination.next")}
<ChevronRight className="w-4 h-4" />
</Button>
</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">
{t("pages:events.stats.upcoming")}
</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">
{t("pages:events.stats.found")}
</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">
{t("pages:events.stats.today")}
</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">
{t("pages:events.stats.upcoming")}
</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">
{t("pages:events.stats.today")}
</div>
</div>
</div>
)}
</div>
)}
</div>
{/* Event Details Modal */}
@ -522,7 +511,6 @@ export function EventsPage() {
onEdit={() => selectedEvent && handleEditEvent(selectedEvent)}
canEdit={selectedEvent ? canEditEvent(selectedEvent) : false}
/>
</main>
{/* Render child routes (e.g. EventModal) */}

View file

@ -1,12 +1,7 @@
import { toast } from "@xtablo/shared";
import { LoadingSpinner } from "@ui/components/LoadingSpinner";
import { toast } from "@xtablo/shared";
import { Button } from "@xtablo/ui/components/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@xtablo/ui/components/dialog";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@xtablo/ui/components/dialog";
import {
DropdownMenu,
DropdownMenuContent,
@ -25,18 +20,18 @@ import {
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link, useSearchParams } from "react-router-dom";
import {
extractFolderIdFromFileName,
getFileNameWithoutFolder,
getFolderFilePrefix,
useTabloFolders,
} from "../hooks/tablo_folders";
import {
useAllTablosFileNames,
useCreateTabloFile,
useDeleteTabloFile,
useDownloadTabloFile,
} from "../hooks/tablo_data";
import {
extractFolderIdFromFileName,
getFileNameWithoutFolder,
getFolderFilePrefix,
useTabloFolders,
} from "../hooks/tablo_folders";
import { useTablosList } from "../hooks/tablos";
// Derive icon color from file extension
@ -146,7 +141,9 @@ function UploadModal({
>
{tablo.name.charAt(0).toUpperCase()}
</span>
<span className="text-sm text-gray-900 dark:text-gray-100 truncate">{tablo.name}</span>
<span className="text-sm text-gray-900 dark:text-gray-100 truncate">
{tablo.name}
</span>
</button>
))}
</div>
@ -155,7 +152,9 @@ function UploadModal({
{/* Folder selector (optional) */}
{folders.length > 0 && (
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Folder (optional)</label>
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
Folder (optional)
</label>
<div className="flex flex-wrap gap-2">
<button
type="button"
@ -319,8 +318,12 @@ function TabloFilesSection({
<div key={folder.id} className="mb-4">
<div className="flex items-center gap-2 mb-2 px-1">
<FolderIcon className="w-4 h-4 text-amber-500 dark:text-amber-400" />
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">{folder.name}</span>
<span className="text-xs text-gray-400 dark:text-gray-500">({folderFiles.length})</span>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
{folder.name}
</span>
<span className="text-xs text-gray-400 dark:text-gray-500">
({folderFiles.length})
</span>
</div>
<FileTable
fileNames={folderFiles}
@ -336,7 +339,9 @@ function TabloFilesSection({
<>
{folders.length > 0 && (
<div className="flex items-center gap-2 mb-2 px-1">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Other files</span>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
Other files
</span>
<span className="text-xs text-gray-400 dark:text-gray-500">({rootFiles.length})</span>
</div>
)}
@ -368,7 +373,9 @@ function FileTable({
<table className="w-full">
<thead className="border-y border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/80">
<tr>
<th className="px-6 py-3 text-left text-sm font-normal text-gray-900 dark:text-gray-300">File name</th>
<th className="px-6 py-3 text-left text-sm font-normal text-gray-900 dark:text-gray-300">
File name
</th>
<th className="px-6 py-3 w-12" />
</tr>
</thead>
@ -377,7 +384,10 @@ function FileTable({
const displayName = getFileNameWithoutFolder(fileName);
const iconColor = getFileIconColor(displayName);
return (
<tr key={fileName} className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors group">
<tr
key={fileName}
className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors group"
>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div
@ -385,7 +395,9 @@ function FileTable({
>
<FileTextIcon className="w-5 h-5 text-white" />
</div>
<p className="text-sm font-normal text-gray-900 dark:text-gray-100">{displayName}</p>
<p className="text-sm font-normal text-gray-900 dark:text-gray-100">
{displayName}
</p>
</div>
</td>
<td className="px-6 py-4">
@ -444,8 +456,9 @@ export function FilesPage() {
const files = filesByTabloId.get(tablo.id) ?? [];
const visibleFiles = files.filter((f) => !f.startsWith("."));
if (searchQuery) {
return visibleFiles.some((f) =>
f.toLowerCase().includes(searchQuery) || tablo.name.toLowerCase().includes(searchQuery)
return visibleFiles.some(
(f) =>
f.toLowerCase().includes(searchQuery) || tablo.name.toLowerCase().includes(searchQuery)
);
}
return visibleFiles.length > 0;
@ -455,7 +468,9 @@ export function FilesPage() {
<div className="py-6 px-6">
{/* Header */}
<div className="flex items-center justify-between pb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">{t("files", "Files")}</h1>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
{t("files", "Files")}
</h1>
<Button
className="gap-2 bg-[#804EEC] hover:bg-[#6f3fd4] text-white"
onClick={() => setUploadOpen(true)}
@ -490,11 +505,7 @@ export function FilesPage() {
)}
{tablos && tablos.length > 0 && (
<UploadModal
isOpen={uploadOpen}
onClose={() => setUploadOpen(false)}
tablos={tablos}
/>
<UploadModal isOpen={uploadOpen} onClose={() => setUploadOpen(false)} tablos={tablos} />
)}
</div>
);

View file

@ -5,13 +5,7 @@ import { Button } from "@xtablo/ui/components/button";
import { FieldError } from "@xtablo/ui/components/field";
import { Input } from "@xtablo/ui/components/input";
import { Label } from "@xtablo/ui/components/label";
import {
ArrowLeftIcon,
MonitorIcon,
MoonIcon,
SparklesIcon,
SunIcon,
} from "lucide-react";
import { ArrowLeftIcon, MonitorIcon, MoonIcon, SparklesIcon, SunIcon } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Link, useSearchParams } from "react-router-dom";
@ -96,27 +90,14 @@ export function LoginV2Page() {
</div>
<div className="mb-6">
<img
src="/logo_dark.png"
alt="Xtablo"
className="h-10 w-auto block dark:hidden"
/>
<img
src="/logo_white.png"
alt="Xtablo"
className="h-10 w-auto hidden dark:block"
/>
<img src="/logo_dark.png" alt="Xtablo" className="h-10 w-auto block dark:hidden" />
<img src="/logo_white.png" alt="Xtablo" className="h-10 w-auto hidden dark:block" />
</div>
<h1 className="text-3xl font-bold tracking-tight mb-2">
{t("auth:login.title")}
</h1>
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("auth:login.title")}</h1>
<p className="text-sm text-muted-foreground mb-8">
{t("auth:login.noAccount")}{" "}
<Link
to="/signup-v2"
className="text-[#804EEC] hover:text-[#6f3fd4] font-semibold"
>
<Link to="/signup-v2" className="text-[#804EEC] hover:text-[#6f3fd4] font-semibold">
{t("auth:login.signupLink")}
</Link>
</p>
@ -124,46 +105,36 @@ export function LoginV2Page() {
<form className="space-y-4" onSubmit={onSubmit}>
<div className="space-y-2">
<Label htmlFor="email">
{t("common:labels.email")}{" "}
<span className="text-red-500">*</span>
{t("common:labels.email")} <span className="text-red-500">*</span>
</Label>
<Input
id="email"
name="email"
type="email"
value={formData.email}
onChange={(e) =>
setFormData({ ...formData, email: e.target.value })
}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
required
placeholder={t("auth:login.emailPlaceholder")}
className="h-11"
/>
{errors?.email && (
<FieldError errors={[{ message: errors.email }]} />
)}
{errors?.email && <FieldError errors={[{ message: errors.email }]} />}
</div>
<div className="space-y-2">
<Label htmlFor="password">
{t("common:labels.password")}{" "}
<span className="text-red-500">*</span>
{t("common:labels.password")} <span className="text-red-500">*</span>
</Label>
<Input
id="password"
name="password"
type="password"
value={formData.password}
onChange={(e) =>
setFormData({ ...formData, password: e.target.value })
}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
required
placeholder={t("auth:login.passwordPlaceholder")}
className="h-11"
/>
{errors?.password && (
<FieldError errors={[{ message: errors.password }]} />
)}
{errors?.password && <FieldError errors={[{ message: errors.password }]} />}
</div>
<div className="flex items-center justify-end">
@ -179,9 +150,7 @@ export function LoginV2Page() {
className="w-full h-11 bg-[#804EEC] hover:bg-[#6f3fd4] text-white"
type="submit"
>
{isPending
? t("auth:common.connecting")
: t("auth:login.loginButton")}
{isPending ? t("auth:common.connecting") : t("auth:login.loginButton")}
</Button>
</form>
@ -245,9 +214,7 @@ export function LoginV2Page() {
</div>
</div>
<h2 className="text-2xl font-bold text-center mb-3">
{t("auth:login.asideTitle")}
</h2>
<h2 className="text-2xl font-bold text-center mb-3">{t("auth:login.asideTitle")}</h2>
<p className="text-center text-muted-foreground mb-8">
{t("auth:login.asideDescription")}
</p>

View file

@ -50,7 +50,7 @@ export function LoginPage() {
const rotateY = ((x - centerX) / centerX) * 1;
setTransform(
`perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg) scale3d(1.002, 1.002, 1.002)`,
`perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg) scale3d(1.002, 1.002, 1.002)`
);
setIsHovered(true);
};
@ -101,7 +101,7 @@ export function LoginPage() {
ref={cardRef}
className={twMerge(
"w-full max-w-lg rounded-2xl relative",
"transition-transform duration-200 ease-out will-change-transform",
"transition-transform duration-200 ease-out will-change-transform"
)}
style={{ transform }}
onMouseMove={handleMouseMove}
@ -116,7 +116,7 @@ export function LoginPage() {
"relative w-full h-full p-8 bg-card/80 backdrop-blur-md rounded-2xl border border-border z-10 transition-shadow duration-200",
isHovered
? "shadow-[0_15px_35px_rgba(0,0,0,0.15)] dark:shadow-[0_15px_35px_rgba(0,0,0,0.3)]"
: "shadow-xl shadow-black/10 dark:shadow-black/25",
: "shadow-xl shadow-black/10 dark:shadow-black/25"
)}
>
<div className="mb-6 flex items-center justify-between">
@ -124,12 +124,7 @@ export function LoginPage() {
href="https://www.xtablo.com"
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<svg
className="w-4 h-4 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
@ -180,50 +175,37 @@ export function LoginPage() {
</div>
<div className="space-y-4 flex flex-col items-center">
<form
className="space-y-4 w-95 max-w-md mx-auto"
onSubmit={onSubmit}
>
<form className="space-y-4 w-95 max-w-md mx-auto" onSubmit={onSubmit}>
<div className="space-y-2">
<Label htmlFor="email">
{t("common:labels.email")}{" "}
<span className="text-red-500">*</span>
{t("common:labels.email")} <span className="text-red-500">*</span>
</Label>
<Input
id="email"
name="email"
type="email"
value={formData.email}
onChange={(e) =>
setFormData({ ...formData, email: e.target.value })
}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
required
placeholder={t("auth:login.emailPlaceholder")}
/>
{errors?.email && (
<FieldError errors={[{ message: errors.email }]} />
)}
{errors?.email && <FieldError errors={[{ message: errors.email }]} />}
</div>
<div className="space-y-2">
<Label htmlFor="password">
{t("common:labels.password")}{" "}
<span className="text-red-500">*</span>
{t("common:labels.password")} <span className="text-red-500">*</span>
</Label>
<Input
id="password"
name="password"
type="password"
value={formData.password}
onChange={(e) =>
setFormData({ ...formData, password: e.target.value })
}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
required
placeholder={t("auth:login.passwordPlaceholder")}
/>
{errors?.password && (
<FieldError errors={[{ message: errors.password }]} />
)}
{errors?.password && <FieldError errors={[{ message: errors.password }]} />}
</div>
<div className="flex items-center justify-end">
@ -236,9 +218,7 @@ export function LoginPage() {
</div>
<Button className="w-full" type="submit">
{isPending
? t("auth:common.connecting")
: t("auth:login.loginButton")}
{isPending ? t("auth:common.connecting") : t("auth:login.loginButton")}
</Button>
</form>
@ -255,7 +235,7 @@ export function LoginPage() {
"rounded-full",
"relative z-10",
"before:absolute before:w-[100px] before:h-px before:bg-border before:left-[-110px] before:top-1/2",
"after:absolute after:w-[100px] after:h-px after:bg-border after:right-[-110px] after:top-1/2",
"after:absolute after:w-[100px] after:h-px after:bg-border after:right-[-110px] after:top-1/2"
)}
>
{t("auth:common.orContinue")}

View file

@ -39,16 +39,11 @@ import {
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Outlet,
useNavigate,
useParams,
useSearchParams,
} from "react-router-dom";
import { Outlet, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { EventModal } from "../components/EventModal";
import { useDeleteEvent, useEventsByTablo } from "../hooks/events";
import { useGetAllTabloAccess, useTablosList } from "../hooks/tablos";
import { useIsReadOnlyUser } from "../providers/UserStoreProvider";
import { EventModal } from "../components/EventModal";
type ViewType = "month" | "week" | "day";
@ -100,14 +95,10 @@ export const PlanningPage = () => {
// Initialize view from URL search params, default to "month"
const viewFromUrl = searchParams.get("view") as ViewType | null;
const initialView: ViewType =
viewFromUrl && ["month", "week", "day"].includes(viewFromUrl)
? viewFromUrl
: "month";
viewFromUrl && ["month", "week", "day"].includes(viewFromUrl) ? viewFromUrl : "month";
const [currentView, setCurrentView] = useState<ViewType>(initialView);
const [selectedTabloId, setSelectedTabloId] = useState<string>(
tablo_id || "all",
);
const [selectedTabloId, setSelectedTabloId] = useState<string>(tablo_id || "all");
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
const [isWebcalModalOpen, setIsWebcalModalOpen] = useState(false);
const isReadOnly = useIsReadOnlyUser();
@ -119,8 +110,9 @@ export const PlanningPage = () => {
const { data: tablos, isLoading: tablosLoading } = useTablosList();
// Fetch events for selected tablo or all tablos
const { data: tabloEvents = [], isLoading: tabloEventsLoading } =
useEventsByTablo(selectedTabloId !== "all" ? selectedTabloId : null);
const { data: tabloEvents = [], isLoading: tabloEventsLoading } = useEventsByTablo(
selectedTabloId !== "all" ? selectedTabloId : null
);
// Fetch all tablo accesses
const { data: tabloAccess } = useGetAllTabloAccess();
@ -131,7 +123,7 @@ export const PlanningPage = () => {
if (
tabloAccess?.find(
(access: { tablo_id: string; is_admin: boolean }) =>
access.tablo_id === event.tablo_id && access.is_admin,
access.tablo_id === event.tablo_id && access.is_admin
)
) {
return true;
@ -154,17 +146,13 @@ export const PlanningPage = () => {
return newParams;
});
},
[setSearchParams],
[setSearchParams]
);
// Sync view with URL on mount and when URL changes
useEffect(() => {
const viewParam = searchParams.get("view") as ViewType | null;
if (
viewParam &&
["month", "week", "day"].includes(viewParam) &&
viewParam !== currentView
) {
if (viewParam && ["month", "week", "day"].includes(viewParam) && viewParam !== currentView) {
setCurrentView(viewParam);
} else if (!viewParam) {
// If no view param in URL, set it to current view
@ -174,7 +162,7 @@ export const PlanningPage = () => {
newParams.set("view", currentView);
return newParams;
},
{ replace: true },
{ replace: true }
);
}
}, [searchParams, currentView, setSearchParams]);
@ -225,8 +213,7 @@ export const PlanningPage = () => {
const calendarName =
selectedTabloId === "all"
? t("planning:allEvents")
: tablos?.find((t) => t.id === selectedTabloId)?.name ||
t("planning:title");
: tablos?.find((t) => t.id === selectedTabloId)?.name || t("planning:title");
const icsContent = generateICSFromEvents(tabloEvents, calendarName);
const filename =
@ -355,12 +342,7 @@ export const PlanningPage = () => {
// const nowMinute = now.getMinutes();
const nowDay = now.getDate();
fullDate.setHours(
Number(time.split(":")[0]),
Number(time.split(":")[1]),
0,
0,
);
fullDate.setHours(Number(time.split(":")[0]), Number(time.split(":")[1]), 0, 0);
const hour = fullDate.getHours();
// const minute = fullDate.getMinutes();
@ -428,8 +410,7 @@ export const PlanningPage = () => {
const daysInMonth = lastDay.getDate();
// Adjust for Monday as first day of week
const startingDayOfWeek = firstDay.getDay();
const mondayStartingDay =
startingDayOfWeek === 0 ? 6 : startingDayOfWeek - 1;
const mondayStartingDay = startingDayOfWeek === 0 ? 6 : startingDayOfWeek - 1;
const days = [];
for (let i = 0; i < mondayStartingDay; i++) {
@ -466,9 +447,7 @@ export const PlanningPage = () => {
if (currentView === "week") {
const weekDays = getWeekDays();
const weekDateStrings = weekDays.map(formatDate);
return tabloEvents.filter((event) =>
weekDateStrings.includes(event.start_date),
);
return tabloEvents.filter((event) => weekDateStrings.includes(event.start_date));
} else if (currentView === "day") {
const dateString = formatDate(currentDate);
return tabloEvents.filter((event) => event.start_date === dateString);
@ -489,7 +468,7 @@ export const PlanningPage = () => {
...visibleEvents.map((event) => {
const [hour] = event.start_time.split(":").map(Number);
return hour;
}),
})
);
// Return the earlier of 8am or the earliest event hour
@ -513,7 +492,7 @@ export const PlanningPage = () => {
}
const [hour] = event.end_time.split(":").map(Number);
return hour;
}),
})
);
// Return the later of 7pm or the latest event hour
@ -540,7 +519,7 @@ export const PlanningPage = () => {
return Array.from(
{ length: numSlots },
(_, i) => `${(startHour + i).toString().padStart(2, "0")}:00`,
(_, i) => `${(startHour + i).toString().padStart(2, "0")}:00`
);
};
@ -568,9 +547,7 @@ export const PlanningPage = () => {
className={`min-h-[120px] border-b border-border ${
(index + 1) % 7 !== 0 ? "border-r border-border" : ""
} ${day ? "cursor-pointer hover:bg-muted" : "bg-muted"} ${
day && formatDate(day) === formatDate(new Date())
? "bg-primary/10"
: ""
day && formatDate(day) === formatDate(new Date()) ? "bg-primary/10" : ""
}`}
onClick={() => {
if (day) {
@ -588,9 +565,7 @@ export const PlanningPage = () => {
<div className="p-2">
<div
className={`text-sm font-medium mb-1 ${
formatDate(day) === formatDate(new Date())
? "text-primary"
: "text-foreground"
formatDate(day) === formatDate(new Date()) ? "text-primary" : "text-foreground"
}`}
>
{day.getDate()}
@ -617,18 +592,14 @@ export const PlanningPage = () => {
onClick={(e) => {
e.stopPropagation();
if (canEditEvent(event)) {
navigate(
`/planning/${event.tablo_id}/events/${event.event_id}/edit`,
);
navigate(`/planning/${event.tablo_id}/events/${event.event_id}/edit`);
}
}}
>
<div className="truncate">
{formatTime(event.start_time)} {event.title}
{selectedTabloId === "all" && event.tablo_name && (
<span className="opacity-75 ml-1">
{event.tablo_name}
</span>
<span className="opacity-75 ml-1"> {event.tablo_name}</span>
)}
</div>
{canDeleteEvent(event) && (
@ -676,9 +647,7 @@ export const PlanningPage = () => {
</div>
<div
className={`text-lg font-medium mt-1 ${
formatDate(day) === formatDate(new Date())
? "text-primary"
: "text-foreground"
formatDate(day) === formatDate(new Date()) ? "text-primary" : "text-foreground"
}`}
>
{day.getDate()}
@ -720,18 +689,10 @@ export const PlanningPage = () => {
)}
{getEventsForDate(day)
.filter((event) =>
event.start_time.startsWith(time.split(":")[0]),
)
.filter((event) => event.start_time.startsWith(time.split(":")[0]))
.map((event) => {
const eventHeight = calculateEventHeight(
event.start_time,
event.end_time,
);
const eventOffset = calculateEventOffset(
event.start_time,
time,
);
const eventHeight = calculateEventHeight(event.start_time, event.end_time);
const eventOffset = calculateEventOffset(event.start_time, time);
return (
<div
key={event.event_id}
@ -748,7 +709,7 @@ export const PlanningPage = () => {
minHeight: "30px",
}}
title={`${formatTime(event.start_time)} - ${formatTime(
event.end_time,
event.end_time
)} ${event.title}${
selectedTabloId === "all" && event.tablo_name
? ` - ${event.tablo_name}`
@ -757,24 +718,19 @@ export const PlanningPage = () => {
onClick={(e) => {
e.stopPropagation();
if (canEditEvent(event)) {
navigate(
`/planning/${event.tablo_id}/events/${event.event_id}/edit`,
);
navigate(`/planning/${event.tablo_id}/events/${event.event_id}/edit`);
}
}}
>
<div className="text-[10px] font-medium leading-tight">
{event.title}
{selectedTabloId === "all" && event.tablo_name && (
<span className="opacity-75 ml-1">
{event.tablo_name}
</span>
<span className="opacity-75 ml-1"> {event.tablo_name}</span>
)}
</div>
{eventHeight >= 30 && (
<div className="text-[9px] opacity-75 leading-tight">
{formatTime(event.start_time)} -{" "}
{formatTime(event.end_time)}
{formatTime(event.start_time)} - {formatTime(event.end_time)}
</div>
)}
{canDeleteEvent(event) && (
@ -807,9 +763,7 @@ export const PlanningPage = () => {
<div className="text-sm text-muted-foreground uppercase">
{dayNames[currentDate.getDay()]}
</div>
<div className="text-2xl font-medium text-foreground mt-1">
{currentDate.getDate()}
</div>
<div className="text-2xl font-medium text-foreground mt-1">{currentDate.getDate()}</div>
</div>
{/* Time slots */}
@ -842,18 +796,10 @@ export const PlanningPage = () => {
)}
{getEventsForDate(currentDate)
.filter((event) =>
event.start_time.startsWith(time.split(":")[0]),
)
.filter((event) => event.start_time.startsWith(time.split(":")[0]))
.map((event) => {
const eventHeight = calculateEventHeight(
event.start_time,
event.end_time,
);
const eventOffset = calculateEventOffset(
event.start_time,
time,
);
const eventHeight = calculateEventHeight(event.start_time, event.end_time);
const eventOffset = calculateEventOffset(event.start_time, time);
return (
<div
key={event.event_id}
@ -870,7 +816,7 @@ export const PlanningPage = () => {
minHeight: "30px",
}}
title={`${formatTime(event.start_time)} - ${formatTime(
event.end_time,
event.end_time
)} ${event.title}${
selectedTabloId === "all" && event.tablo_name
? ` - ${event.tablo_name}`
@ -879,24 +825,19 @@ export const PlanningPage = () => {
onClick={(e) => {
e.stopPropagation();
if (canEditEvent(event)) {
navigate(
`/planning/${event.tablo_id}/events/${event.event_id}/edit`,
);
navigate(`/planning/${event.tablo_id}/events/${event.event_id}/edit`);
}
}}
>
<div className="text-[10px] font-medium truncate leading-tight">
{event.title}
{selectedTabloId === "all" && event.tablo_name && (
<span className="opacity-75 ml-1">
{event.tablo_name}
</span>
<span className="opacity-75 ml-1"> {event.tablo_name}</span>
)}
</div>
{eventHeight >= 30 && (
<div className="text-[9px] opacity-75 leading-tight">
{formatTime(event.start_time)} -{" "}
{formatTime(event.end_time)}
{formatTime(event.start_time)} - {formatTime(event.end_time)}
</div>
)}
{eventHeight >= 75 && event.description && (
@ -932,9 +873,7 @@ export const PlanningPage = () => {
today.setHours(0, 0, 0, 0);
const filtered = tabloEvents.filter((e) => {
if (showAllEvents) return true;
const eventDate = e.start_date
? new Date(e.start_date + "T00:00:00")
: null;
const eventDate = e.start_date ? new Date(`${e.start_date}T00:00:00`) : null;
return !eventDate || eventDate >= today;
});
@ -1014,21 +953,15 @@ export const PlanningPage = () => {
Aucun événement trouvé
</p>
<p className="text-gray-400 dark:text-gray-500 text-sm mt-1">
{showAllEvents
? "Aucun événement trouvé"
: "Aucun événement à venir"}
{showAllEvents ? "Aucun événement trouvé" : "Aucun événement à venir"}
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{filtered.map((event) => {
const date = event.start_date
? new Date(event.start_date + "T00:00:00")
: null;
const date = event.start_date ? new Date(`${event.start_date}T00:00:00`) : null;
const monthLabel = date ? months[date.getMonth()] : "";
const dayLabel = date
? String(date.getDate()).padStart(2, "0")
: "";
const dayLabel = date ? String(date.getDate()).padStart(2, "0") : "";
const TabloIcon = getTabloIcon(event.tablo_color);
const iconColor = getTabloIconColor(event.tablo_color);
const timeLabel = event.start_time
@ -1060,11 +993,7 @@ export const PlanningPage = () => {
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => deleteEvent.mutate(event.event_id)}
disabled={
!canDeleteEvent(event) ||
isReadOnly ||
deleteEvent.isPending
}
disabled={!canDeleteEvent(event) || isReadOnly || deleteEvent.isPending}
className="text-destructive focus:text-destructive"
>
Supprimer
@ -1113,9 +1042,7 @@ export const PlanningPage = () => {
>
<TabloIcon className={`w-3 h-3 ${iconColor}`} />
</div>
<span className="text-sm truncate">
{event.tablo_name}
</span>
<span className="text-sm truncate">{event.tablo_name}</span>
</div>
)}
</div>
@ -1136,9 +1063,7 @@ export const PlanningPage = () => {
mode="create"
isOpen={isCreateEventOpen}
onClose={() => setIsCreateEventOpen(false)}
defaultTabloId={
selectedTabloId !== "all" ? selectedTabloId : undefined
}
defaultTabloId={selectedTabloId !== "all" ? selectedTabloId : undefined}
defaultDate={currentDate}
/>
<Outlet />
@ -1159,15 +1084,10 @@ export const PlanningPage = () => {
onValueChange={(value) => setSelectedTabloId(value)}
disabled={tablosLoading}
>
<SelectTrigger
className="w-full"
aria-label={t("planning:selectTablo")}
>
<SelectTrigger className="w-full" aria-label={t("planning:selectTablo")}>
<SelectValue
placeholder={
tablosLoading
? t("common:actions.loading")
: t("planning:selectTablo")
tablosLoading ? t("common:actions.loading") : t("planning:selectTablo")
}
/>
</SelectTrigger>
@ -1192,17 +1112,15 @@ export const PlanningPage = () => {
"Vous êtes en mode lecture seule. Vous ne pouvez pas créer d'événement.",
type: "error",
},
{ timeout: 5000 },
{ timeout: 5000 }
);
return;
}
if (selectedTabloId === "all") {
navigate(
`/planning/create?date=${currentDate.toISOString()}`,
);
navigate(`/planning/create?date=${currentDate.toISOString()}`);
} else {
navigate(
`/planning/create?tablo_id=${selectedTabloId}&date=${currentDate.toISOString()}`,
`/planning/create?tablo_id=${selectedTabloId}&date=${currentDate.toISOString()}`
);
}
}}
@ -1223,7 +1141,7 @@ export const PlanningPage = () => {
"Vous êtes en mode lecture seule. Vous ne pouvez pas importer de calendrier.",
type: "error",
},
{ timeout: 5000 },
{ timeout: 5000 }
);
return;
}
@ -1256,10 +1174,7 @@ export const PlanningPage = () => {
</div>
<div className="grid grid-cols-7 gap-1 text-xs">
{dayNamesShort.map((day) => (
<div
key={day}
className="text-center text-muted-foreground p-1"
>
<div key={day} className="text-center text-muted-foreground p-1">
{day.slice(0, 1)}
</div>
))}
@ -1304,16 +1219,8 @@ export const PlanningPage = () => {
{t("planning:today")}
</Button>
<div className="flex items-center space-x-2">
<button
onClick={() => navigateDate(-1)}
className="p-2 hover:bg-muted rounded"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<button onClick={() => navigateDate(-1)} className="p-2 hover:bg-muted rounded">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
@ -1322,16 +1229,8 @@ export const PlanningPage = () => {
/>
</svg>
</button>
<button
onClick={() => navigateDate(1)}
className="p-2 hover:bg-muted rounded"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<button onClick={() => navigateDate(1)} className="p-2 hover:bg-muted rounded">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
@ -1386,9 +1285,7 @@ export const PlanningPage = () => {
alt="Loading..."
className="animate-spin rounded-full h-8 w-8 object-cover"
/>
<span className="ml-2 text-muted-foreground">
{t("planning:loadingEvents")}
</span>
<span className="ml-2 text-muted-foreground">{t("planning:loadingEvents")}</span>
</div>
) : (
<>
@ -1403,14 +1300,9 @@ export const PlanningPage = () => {
<Outlet />
{isImportModalOpen && (
<ImportICSModal onClose={() => setIsImportModalOpen(false)} />
)}
{isImportModalOpen && <ImportICSModal onClose={() => setIsImportModalOpen(false)} />}
<WebcalModal
open={isWebcalModalOpen}
onOpenChange={setIsWebcalModalOpen}
/>
<WebcalModal open={isWebcalModalOpen} onOpenChange={setIsWebcalModalOpen} />
</div>
);
};

View file

@ -2,11 +2,7 @@ import { LoadingSpinner } from "@ui/components/LoadingSpinner";
import { cn, toast } from "@xtablo/shared";
import type { UserTablo } from "@xtablo/shared/types/tablos.types";
import type { Etape, KanbanTask } from "@xtablo/shared-types";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@xtablo/ui/components/avatar";
import { Avatar, AvatarFallback, AvatarImage } from "@xtablo/ui/components/avatar";
import { Button } from "@xtablo/ui/components/button";
import {
Dialog,
@ -43,12 +39,7 @@ import {
Zap,
} from "lucide-react";
import { useEffect, useState } from "react";
import {
Link,
useNavigate,
useParams,
useSearchParams,
} from "react-router-dom";
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { GanttChart } from "../components/gantt/GanttChart";
import { TaskModal } from "../components/kanban/TaskModal";
import { TabloDiscussionSection } from "../components/TabloDiscussionSection";
@ -58,10 +49,7 @@ import { TabloTasksSection } from "../components/TabloTasksSection";
import { useTabloDiscussionUnread } from "../hooks/channel";
import { useInviteUser } from "../hooks/invite";
import { useTabloFileNames } from "../hooks/tablo_data";
import {
useCancelTabloInvite,
usePendingTabloInvitesByTablo,
} from "../hooks/tablo_invites";
import { useCancelTabloInvite, usePendingTabloInvitesByTablo } from "../hooks/tablo_invites";
import { useTabloMembers, useTablosList } from "../hooks/tablos";
import {
useAllTasks,
@ -176,9 +164,9 @@ export const TabloDetailsPage = () => {
const { hasUnread: hasUnreadDiscussion } = useTabloDiscussionUnread(tabloId);
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
const [taskModalInitialDueDate, setTaskModalInitialDueDate] = useState<
Date | undefined
>(undefined);
const [taskModalInitialDueDate, setTaskModalInitialDueDate] = useState<Date | undefined>(
undefined
);
const [showAllOverviewTasks, setShowAllOverviewTasks] = useState(false);
const [isShareDialogOpen, setIsShareDialogOpen] = useState(false);
const [inviteEmail, setInviteEmail] = useState("");
@ -186,8 +174,7 @@ export const TabloDetailsPage = () => {
const currentUser = useUser();
const { data: members } = useTabloMembers(tabloId ?? "");
const { data: pendingInvites } = usePendingTabloInvitesByTablo(tabloId ?? "");
const { mutate: cancelInvite, isPending: isCancellingInvite } =
useCancelTabloInvite();
const { mutate: cancelInvite, isPending: isCancellingInvite } = useCancelTabloInvite();
const { mutate: inviteUser, isPending: isInvitingUser } = useInviteUser();
const { mutate: updateTask } = useUpdateTask();
@ -204,8 +191,7 @@ export const TabloDetailsPage = () => {
};
const filteredMembers = members?.filter(
(member) =>
!pendingInvites?.some((invite) => invite.invited_email === member.email),
(member) => !pendingInvites?.some((invite) => invite.invited_email === member.email)
);
const openTaskModal = (dueDate?: Date) => {
@ -235,11 +221,10 @@ export const TabloDetailsPage = () => {
toast.add(
{
title: "Projet introuvable",
description:
"Le projet demandé n'existe pas ou vous n'y avez pas accès",
description: "Le projet demandé n'existe pas ou vous n'y avez pas accès",
type: "error",
},
{ timeout: 5000 },
{ timeout: 5000 }
);
navigate("/tablos");
}
@ -248,22 +233,16 @@ export const TabloDetailsPage = () => {
// Tasks for this tablo (used in overview)
const { data: allTasks = [] } = useAllTasks();
const tabloTasks = (allTasks as KanbanTask[]).filter(
(t) => t.tablo_id === tabloId,
);
const tabloTasks = (allTasks as KanbanTask[]).filter((t) => t.tablo_id === tabloId);
const myTabloTasks = tabloTasks.filter((task) => task.assignee_id === currentUser.id);
const visibleOverviewTasks = showAllOverviewTasks
? myTabloTasks
: myTabloTasks.slice(0, 5);
const visibleOverviewTasks = showAllOverviewTasks ? myTabloTasks : myTabloTasks.slice(0, 5);
// Etapes (parent tasks) for this tablo
const { data: etapes = [] } = useTabloEtapes(tabloId);
// Files for this tablo (used in overview)
const { data: filesData } = useTabloFileNames(tabloId ?? "");
const fileNames = (filesData?.fileNames ?? []).filter(
(f) => !f.startsWith("."),
);
const fileNames = (filesData?.fileNames ?? []).filter((f) => !f.startsWith("."));
if (isLoading) {
return (
@ -290,22 +269,16 @@ export const TabloDetailsPage = () => {
<div
className={cn(
"w-12 h-12 rounded-lg flex items-center justify-center shrink-0 overflow-hidden",
!tablo.image && (tablo.color || "bg-gray-400"),
!tablo.image && (tablo.color || "bg-gray-400")
)}
>
{tablo.image ? (
<img
src={tablo.image}
alt={tablo.name}
className="w-full h-full object-cover"
/>
<img src={tablo.image} alt={tablo.name} className="w-full h-full object-cover" />
) : (
<TabloIcon className={cn("w-6 h-6", iconColor)} />
)}
</div>
<h1 className="text-xl md:text-3xl font-bold text-foreground">
{tablo.name}
</h1>
<h1 className="text-xl md:text-3xl font-bold text-foreground">{tablo.name}</h1>
</div>
<div className="flex flex-wrap items-center gap-3">
@ -333,9 +306,7 @@ export const TabloDetailsPage = () => {
<div className="flex flex-wrap items-center gap-6 text-sm border-b border-[#F2F4F7] dark:border-gray-700 pb-4 mb-4">
<div className="flex items-center gap-2 md:border-r border-[#D0D5DD] dark:border-gray-600 pr-4">
<span className="text-muted-foreground">Rôle :</span>
<span className="text-foreground font-medium">
{isAdmin ? "Admin" : "Invité"}
</span>
<span className="text-foreground font-medium">{isAdmin ? "Admin" : "Invité"}</span>
</div>
<div className="flex items-center gap-2 md:border-r border-[#D0D5DD] dark:border-gray-600 pr-4">
<span className="text-muted-foreground">Créé le :</span>
@ -349,12 +320,7 @@ export const TabloDetailsPage = () => {
</div>
<div className="flex items-center gap-2 md:border-r border-[#D0D5DD] dark:border-gray-600 pr-4">
<span className="text-muted-foreground">Statut :</span>
<span
className={cn(
"px-3 py-1 rounded-full text-xs font-medium",
badgeClass,
)}
>
<span className={cn("px-3 py-1 rounded-full text-xs font-medium", badgeClass)}>
{statusLabel}
</span>
</div>
@ -386,15 +352,13 @@ export const TabloDetailsPage = () => {
key={tab.id}
type="button"
disabled={tab.disabled}
onClick={() =>
!tab.disabled && setSearchParams({ section: tab.id })
}
onClick={() => !tab.disabled && setSearchParams({ section: tab.id })}
className={cn(
"flex items-center gap-2 pb-3 px-1 text-sm font-semibold transition-colors border-b-2",
isActive
? "text-[#804EEC] border-[#804EEC]"
: "text-[#667085] border-transparent hover:text-gray-900 dark:hover:text-gray-100",
tab.disabled && "opacity-40 cursor-not-allowed",
tab.disabled && "opacity-40 cursor-not-allowed"
)}
>
<span className="relative inline-flex">
@ -428,9 +392,8 @@ export const TabloDetailsPage = () => {
Description du projet
</h2>
<p className="text-muted-foreground leading-relaxed text-sm sm:text-base">
Ce projet regroupe les tâches, fichiers et événements
associés. Utilisez les onglets ci-dessus pour naviguer entre
les différentes sections.
Ce projet regroupe les tâches, fichiers et événements associés. Utilisez les
onglets ci-dessus pour naviguer entre les différentes sections.
</p>
</div>
@ -487,7 +450,7 @@ export const TabloDetailsPage = () => {
"text-sm font-medium truncate",
task.status === "done"
? "line-through text-gray-400"
: "text-gray-900 dark:text-gray-100",
: "text-gray-900 dark:text-gray-100"
)}
>
{task.title}
@ -515,9 +478,7 @@ export const TabloDetailsPage = () => {
{/* Files */}
<div className="bg-white dark:bg-card rounded-xl border border-border p-5 sm:p-6 shadow-sm">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-bold text-foreground">
Fichiers
</h3>
<h3 className="text-lg font-bold text-foreground">Fichiers</h3>
<button
type="button"
onClick={() => setSearchParams({ section: "files" })}
@ -528,9 +489,7 @@ export const TabloDetailsPage = () => {
</div>
<div className="space-y-3">
{fileNames.length === 0 ? (
<p className="text-sm text-muted-foreground">
Aucun fichier
</p>
<p className="text-sm text-muted-foreground">Aucun fichier</p>
) : (
fileNames.slice(0, 5).map((fileName) => (
<div
@ -541,9 +500,7 @@ export const TabloDetailsPage = () => {
<FileTextIcon className="w-4 h-4 text-red-500" />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-foreground text-sm truncate">
{fileName}
</p>
<p className="font-medium text-foreground text-sm truncate">{fileName}</p>
</div>
<button
type="button"
@ -559,38 +516,25 @@ export const TabloDetailsPage = () => {
{/* Info */}
<div className="bg-white dark:bg-card rounded-xl border border-border p-5 sm:p-6 shadow-sm">
<h3 className="text-lg font-bold text-foreground mb-4">
Informations
</h3>
<h3 className="text-lg font-bold text-foreground mb-4">Informations</h3>
<dl className="space-y-3 text-sm">
<div className="flex justify-between">
<dt className="text-muted-foreground">Tâches</dt>
<dd className="font-medium text-foreground">
{tabloTasks.length}
</dd>
<dd className="font-medium text-foreground">{tabloTasks.length}</dd>
</div>
<div className="flex justify-between">
<dt className="text-muted-foreground">Fichiers</dt>
<dd className="font-medium text-foreground">
{fileNames.length}
</dd>
<dd className="font-medium text-foreground">{fileNames.length}</dd>
</div>
<div className="flex justify-between">
<dt className="text-muted-foreground">Statut</dt>
<dd
className={cn(
"px-2 py-0.5 rounded-full text-xs font-medium",
badgeClass,
)}
>
<dd className={cn("px-2 py-0.5 rounded-full text-xs font-medium", badgeClass)}>
{statusLabel}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-muted-foreground">Rôle</dt>
<dd className="font-medium text-foreground">
{isAdmin ? "Admin" : "Invité"}
</dd>
<dd className="font-medium text-foreground">{isAdmin ? "Admin" : "Invité"}</dd>
</div>
</dl>
</div>
@ -598,18 +542,12 @@ export const TabloDetailsPage = () => {
</div>
)}
{activeSection === "tasks" && (
<TabloTasksSection tablo={tablo} isAdmin={isAdmin} />
)}
{activeSection === "files" && (
<TabloFilesSection tablo={tablo} isAdmin={isAdmin} />
)}
{activeSection === "tasks" && <TabloTasksSection tablo={tablo} isAdmin={isAdmin} />}
{activeSection === "files" && <TabloFilesSection tablo={tablo} isAdmin={isAdmin} />}
{activeSection === "discussion" && (
<TabloDiscussionSection tablo={tablo} isAdmin={isAdmin} />
)}
{activeSection === "events" && (
<TabloEventsSection tablo={tablo} isAdmin={isAdmin} />
)}
{activeSection === "events" && <TabloEventsSection tablo={tablo} isAdmin={isAdmin} />}
{activeSection === "etapes" && (
<EtapesSection
@ -621,11 +559,7 @@ export const TabloDetailsPage = () => {
)}
{activeSection === "roadmap" && (
<RoadmapSection
etapes={etapes}
tabloTasks={tabloTasks}
onDateClick={openTaskModal}
/>
<RoadmapSection etapes={etapes} tabloTasks={tabloTasks} onDateClick={openTaskModal} />
)}
</div>
@ -645,9 +579,7 @@ export const TabloDetailsPage = () => {
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Partager le projet</DialogTitle>
<DialogDescription>
Invitez des personnes à collaborer sur ce projet
</DialogDescription>
<DialogDescription>Invitez des personnes à collaborer sur ce projet</DialogDescription>
</DialogHeader>
<div className="space-y-4">
@ -737,18 +669,14 @@ export const TabloDetailsPage = () => {
{filteredMembers.map((member) => {
const avatarUrl =
member.avatar_url ??
(member.id === currentUser.id
? currentUser.avatar_url
: null);
(member.id === currentUser.id ? currentUser.avatar_url : null);
return (
<div
key={member.id}
className="flex items-center space-x-2 p-2 bg-muted rounded-lg"
>
<Avatar className="w-8 h-8">
{avatarUrl && (
<AvatarImage src={avatarUrl} alt={member.name} />
)}
{avatarUrl && <AvatarImage src={avatarUrl} alt={member.name} />}
<AvatarFallback className="bg-primary text-primary-foreground text-xs font-medium">
{member.name.charAt(0).toUpperCase()}
</AvatarFallback>
@ -788,16 +716,13 @@ function EtapesSection({
isAdmin: boolean;
}) {
const [expandedEtapes, setExpandedEtapes] = useState<Set<string>>(
new Set(etapes.map((e) => e.id)),
);
const [addingTaskToEtape, setAddingTaskToEtape] = useState<string | null>(
null,
new Set(etapes.map((e) => e.id))
);
const [addingTaskToEtape, setAddingTaskToEtape] = useState<string | null>(null);
const [newEtapeTitle, setNewEtapeTitle] = useState("");
const [newTaskTitle, setNewTaskTitle] = useState("");
const { mutate: createTask } = useCreateTask();
const { mutateAsync: createEtape, isPending: isCreatingEtape } =
useCreateEtape();
const { mutateAsync: createEtape, isPending: isCreatingEtape } = useCreateEtape();
const toggleEtape = (id: string) => {
setExpandedEtapes((prev) => {
@ -829,8 +754,7 @@ function EtapesSection({
return;
}
const nextPosition =
etapes.reduce((max, etape) => Math.max(max, etape.position), -1) + 1;
const nextPosition = etapes.reduce((max, etape) => Math.max(max, etape.position), -1) + 1;
await createEtape({
tabloId,
@ -848,18 +772,15 @@ function EtapesSection({
},
in_progress: {
label: "En cours",
color:
"bg-yellow-100 text-yellow-700 dark:bg-yellow-950/30 dark:text-yellow-400",
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",
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",
color: "bg-green-100 text-green-700 dark:bg-green-950/30 dark:text-green-400",
},
};
@ -891,241 +812,230 @@ function EtapesSection({
{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-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);
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;
// 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-4 px-5 py-4 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors text-left"
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"
>
{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">
{etape.title}
</h3>
{etape.description && (
<p className="text-sm text-muted-foreground truncate mt-0.5">
{etape.description}
</p>
{/* Etape header */}
<button
type="button"
onClick={() => toggleEtape(etape.id)}
className="w-full flex items-center gap-4 px-5 py-4 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors text-left"
>
{isExpanded ? (
<ChevronDownIcon className="w-5 h-5 text-gray-400 shrink-0" />
) : (
<ChevronRightIcon className="w-5 h-5 text-gray-400 shrink-0" />
)}
</div>
{etape.due_date && (
<div
<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">
{etape.title}
</h3>
{etape.description && (
<p className="text-sm text-muted-foreground truncate mt-0.5">
{etape.description}
</p>
)}
</div>
{etape.due_date && (
<div
className={cn(
"flex items-center gap-1 text-xs shrink-0",
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(
"flex items-center gap-1 text-xs shrink-0",
derivedStatus !== "done" &&
new Date(etape.due_date) <
new Date(new Date().toDateString())
? "text-red-500"
: "text-muted-foreground",
"px-2.5 py-1 rounded-full text-xs font-medium shrink-0",
status.color
)}
>
<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>
)}
{status.label}
</span>
<span
className={cn(
"px-2.5 py-1 rounded-full text-xs font-medium shrink-0",
status.color,
)}
>
{status.label}
</span>
{totalCount > 0 && (
<div className="flex items-center gap-2 shrink-0">
<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>
)}
</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) => (
{totalCount > 0 && (
<div className="flex items-center gap-2 shrink-0">
<div className="w-16 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
key={task.id}
className="flex items-center gap-3 px-5 py-3 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",
)}
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>
)}
</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-5 py-3 pl-16 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
{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 && (
{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(
"px-2 py-0.5 rounded-full text-[10px] font-medium shrink-0",
(statusConfig[task.status] ?? statusConfig.todo)
.color,
"text-sm flex-1 truncate",
task.status === "done"
? "line-through text-gray-400"
: "text-gray-900 dark:text-gray-100"
)}
>
{
(statusConfig[task.status] ?? statusConfig.todo)
.label
}
{task.title}
</span>
)}
</div>
))}
</div>
)}
{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-5 py-4 pl-16 text-sm text-muted-foreground">
Aucune tâche dans cette étape
</div>
)}
{childTasks.length === 0 && addingTaskToEtape !== etape.id && (
<div className="px-5 py-4 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-5 py-3 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") {
{/* Inline add task */}
{addingTaskToEtape === etape.id ? (
<div className="flex items-center gap-2 px-5 py-3 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"
/>
<button
type="button"
onClick={() => handleAddTask(etape.id)}
disabled={!newTaskTitle.trim()}
className="text-xs font-medium px-3 py-1 rounded-md bg-[#804EEC] text-white hover:bg-[#6f3fd4] disabled:opacity-40 transition-colors"
>
Ajouter
</button>
<button
type="button"
onClick={() => {
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"
/>
}}
className="text-xs text-muted-foreground hover:text-foreground px-2 py-1"
>
Annuler
</button>
</div>
) : (
<button
type="button"
onClick={() => handleAddTask(etape.id)}
disabled={!newTaskTitle.trim()}
className="text-xs font-medium px-3 py-1 rounded-md bg-[#804EEC] text-white hover:bg-[#6f3fd4] disabled:opacity-40 transition-colors"
>
Ajouter
</button>
<button
type="button"
onClick={() => {
setAddingTaskToEtape(null);
onClick={(e) => {
e.stopPropagation();
setAddingTaskToEtape(etape.id);
setNewTaskTitle("");
}}
className="text-xs text-muted-foreground hover:text-foreground px-2 py-1"
className="flex items-center gap-2 px-5 py-2.5 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"
>
Annuler
<PlusIcon className="w-4 h-4" />
Ajouter une tâche
</button>
</div>
) : (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setAddingTaskToEtape(etape.id);
setNewTaskTitle("");
}}
className="flex items-center gap-2 px-5 py-2.5 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"
>
<PlusIcon className="w-4 h-4" />
Ajouter une tâche
</button>
)}
</div>
)}
</div>
);
})
)}
</div>
)}
</div>
);
})
)}
</div>
);
@ -1148,9 +1058,7 @@ function RoadmapSection({
tasks={tabloTasks}
isLoading={false}
onDateClick={onDateClick}
onTaskStatusChange={(taskId, status) =>
updateTask({ id: taskId, status })
}
onTaskStatusChange={(taskId, status) => updateTask({ id: taskId, status })}
/>
);
}

View file

@ -11,11 +11,7 @@ import {
EmptyHeader,
EmptyTitle,
} from "@xtablo/ui/components/empty";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@xtablo/ui/components/tooltip";
import { Tooltip, TooltipContent, TooltipTrigger } from "@xtablo/ui/components/tooltip";
import { Text } from "@xtablo/ui/components/typography";
import {
CheckCircle2,
@ -41,31 +37,37 @@ import {
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useSearchParams } from "react-router-dom";
import {
useCanCreateTablo,
useCreateTablo,
useDeleteTablo,
useTablosList,
} from "../hooks/tablos";
import { useIsReadOnlyUser, useUser } from "../providers/UserStoreProvider";
import { DashboardActionCards } from "src/components/DashboardActionCards";
import { DashboardTaskList } from "src/components/DashboardTaskList";
import { TaskModal } from "src/components/kanban/TaskModal";
import { ProjectCardList } from "src/components/ProjectCardList";
import { useCanCreateTablo, useCreateTablo, useDeleteTablo, useTablosList } from "../hooks/tablos";
import { useIsReadOnlyUser, useUser } from "../providers/UserStoreProvider";
function getTabloIcon(color: string | null | undefined) {
switch (color) {
case "bg-blue-500": return Zap;
case "bg-green-500": return Leaf;
case "bg-purple-500": return Gem;
case "bg-red-500": return Flame;
case "bg-yellow-500": return Star;
case "bg-indigo-500": return Compass;
case "bg-pink-500": return Heart;
case "bg-teal-500": return Waves;
case "bg-orange-500": return Sun;
case "bg-cyan-500": return Sparkles;
default: return FolderIcon;
case "bg-blue-500":
return Zap;
case "bg-green-500":
return Leaf;
case "bg-purple-500":
return Gem;
case "bg-red-500":
return Flame;
case "bg-yellow-500":
return Star;
case "bg-indigo-500":
return Compass;
case "bg-pink-500":
return Heart;
case "bg-teal-500":
return Waves;
case "bg-orange-500":
return Sun;
case "bg-cyan-500":
return Sparkles;
default:
return FolderIcon;
}
}
@ -119,7 +121,6 @@ export const TabloPage = () => {
const viewMode = (searchParams.get("view") as "grid" | "list") || "list";
const searchQuery = searchParams.get("q")?.toLowerCase() ?? "";
const { data: tablos, isLoading, error } = useTablosList();
const createTabloMutation = useCreateTablo();
// const { mutateAsync: updateTablo } = useUpdateTablo();
@ -133,8 +134,7 @@ export const TabloPage = () => {
(filterType === "inProgress" && tablo.status === "inProgress") ||
(filterType === "done" && tablo.status === "done");
const matchesSearch =
!searchQuery || tablo.name.toLowerCase().includes(searchQuery);
const matchesSearch = !searchQuery || tablo.name.toLowerCase().includes(searchQuery);
return matchesStatus && matchesSearch;
});
@ -146,8 +146,7 @@ export const TabloPage = () => {
},
{
name: "Membres",
action: (tabloId: string) =>
navigate(`/tablos/${tabloId}?section=members`),
action: (tabloId: string) => navigate(`/tablos/${tabloId}?section=members`),
},
];
@ -156,11 +155,10 @@ export const TabloPage = () => {
toast.add(
{
title: t("common:error"),
description:
"Vous êtes en mode lecture seule. Vous ne pouvez pas créer de projet.",
description: "Vous êtes en mode lecture seule. Vous ne pouvez pas créer de projet.",
type: "error",
},
{ timeout: 5000 },
{ timeout: 5000 }
);
return;
}
@ -172,7 +170,7 @@ export const TabloPage = () => {
};
const createNewTablo = async (
tabloData: Pick<TabloInsert, "name" | "color" | "image" | "status">,
tabloData: Pick<TabloInsert, "name" | "color" | "image" | "status">
) => {
try {
await createTabloMutation.mutateAsync(tabloData);
@ -241,9 +239,7 @@ export const TabloPage = () => {
};
const getUserRole = (tablo: UserTablo) => {
return tablo.is_admin
? t("pages:tablo.role.admin")
: t("pages:tablo.role.guest");
return tablo.is_admin ? t("pages:tablo.role.admin") : t("pages:tablo.role.guest");
};
const getRoleColor = (tablo: UserTablo) => {
@ -256,14 +252,11 @@ export const TabloPage = () => {
const totalTablos = tablos.length;
const todoCount = tablos.filter((t) => t.status === "todo").length;
const inProgressCount = tablos.filter(
(t) => t.status === "inProgress",
).length;
const inProgressCount = tablos.filter((t) => t.status === "inProgress").length;
const doneCount = tablos.filter((t) => t.status === "done").length;
const adminCount = tablos.filter((t) => t.is_admin).length;
const guestCount = tablos.filter((t) => !t.is_admin).length;
const completionRate =
totalTablos > 0 ? Math.round((doneCount / totalTablos) * 100) : 0;
const completionRate = totalTablos > 0 ? Math.round((doneCount / totalTablos) * 100) : 0;
return {
totalTablos,
@ -282,15 +275,9 @@ export const TabloPage = () => {
const isCreateDisabled = createTabloMutation.isPending || isReadOnly;
const button = (
<Button
id="create-tablo-button"
onClick={openCreateModal}
disabled={isCreateDisabled}
>
<Button id="create-tablo-button" onClick={openCreateModal} disabled={isCreateDisabled}>
<Plus />
{createTabloMutation.isPending
? t("common:actions.saving")
: t("pages:tablo.createButton")}
{createTabloMutation.isPending ? t("common:actions.saving") : t("pages:tablo.createButton")}
</Button>
);
@ -307,15 +294,9 @@ export const TabloPage = () => {
</TooltipTrigger>
<TooltipContent>
{isReadOnlyUser ? (
<p>
Vous ne pouvez pas créer de tablo car vous êtes en mode lecture
seule.
</p>
<p>Vous ne pouvez pas créer de tablo car vous êtes en mode lecture seule.</p>
) : (
<p>
Vous ne pouvez pas créer de tablo car vous avez atteint votre
limite de tablos.
</p>
<p>Vous ne pouvez pas créer de tablo car vous avez atteint votre limite de tablos.</p>
)}
</TooltipContent>
</Tooltip>
@ -330,12 +311,8 @@ export const TabloPage = () => {
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex justify-between items-start">
<div>
<h1 className="text-3xl font-bold text-foreground">
{t("pages:tablo.title")}
</h1>
<Text className="text-muted-foreground mt-1">
{t("pages:tablo.subtitle")}
</Text>
<h1 className="text-3xl font-bold text-foreground">{t("pages:tablo.title")}</h1>
<Text className="text-muted-foreground mt-1">{t("pages:tablo.subtitle")}</Text>
</div>
{createTabloButton()}
</div>
@ -358,12 +335,8 @@ export const TabloPage = () => {
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex justify-between items-start">
<div>
<h1 className="text-3xl font-bold text-foreground">
{t("pages:tablo.title")}
</h1>
<Text className="text-muted-foreground mt-1">
{t("pages:tablo.subtitle")}
</Text>
<h1 className="text-3xl font-bold text-foreground">{t("pages:tablo.title")}</h1>
<Text className="text-muted-foreground mt-1">{t("pages:tablo.subtitle")}</Text>
</div>
<Button onClick={openCreateModal} disabled={isReadOnly}>
<Plus /> Nouveau projet
@ -374,13 +347,9 @@ export const TabloPage = () => {
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex justify-center items-center min-h-64">
<div className="text-center">
<p className="text-destructive mb-2">
Erreur lors du chargement des projets
</p>
<p className="text-destructive mb-2">Erreur lors du chargement des projets</p>
<p className="text-muted-foreground text-sm">
{error instanceof Error
? error.message
: "Une erreur inconnue s'est produite"}
{error instanceof Error ? error.message : "Une erreur inconnue s'est produite"}
</p>
</div>
</div>
@ -408,9 +377,7 @@ export const TabloPage = () => {
>
<div
className={`bg-card rounded-lg shadow-lg transition-all duration-300 w-56 overflow-hidden border border-border ${
isAdmin
? "hover:shadow-xl cursor-pointer"
: "hover:shadow-xl cursor-pointer opacity-75"
isAdmin ? "hover:shadow-xl cursor-pointer" : "hover:shadow-xl cursor-pointer opacity-75"
}`}
onClick={(e) => {
e.stopPropagation();
@ -420,11 +387,7 @@ export const TabloPage = () => {
{/* Image or Color */}
<div className="relative h-40 group">
{tablo.image ? (
<img
src={tablo.image}
alt={tablo.name}
className="w-full h-full object-cover"
/>
<img src={tablo.image} alt={tablo.name} className="w-full h-full object-cover" />
) : (
<div
className={`w-full h-full ${
@ -447,21 +410,17 @@ export const TabloPage = () => {
<div className="p-3">
<div className="space-y-2">
<div className="flex items-center gap-1">
<h3 className="text-foreground font-semibold text-base truncate">
{tablo.name}
</h3>
<h3 className="text-foreground font-semibold text-base truncate">{tablo.name}</h3>
{/* Status badge */}
<div
className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusBadgeColor(
tablo.status,
tablo.status
)} shrink-0`}
>
<span>{getStatusLabel(tablo.status)}</span>
</div>
</div>
<div
className={`flex items-center gap-1 text-xs font-medium ${getRoleColor(tablo)}`}
>
<div className={`flex items-center gap-1 text-xs font-medium ${getRoleColor(tablo)}`}>
<Shield className="w-3 h-3" />
<span>{getUserRole(tablo)}</span>
</div>
@ -568,12 +527,8 @@ export const TabloPage = () => {
<div className="bg-card rounded-lg shadow-md p-4 border border-border">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">
Total
</p>
<p className="text-2xl font-bold text-foreground mt-1">
{kpis.totalTablos}
</p>
<p className="text-sm font-medium text-muted-foreground">Total</p>
<p className="text-2xl font-bold text-foreground mt-1">{kpis.totalTablos}</p>
</div>
<div className="p-2 bg-primary/10 rounded-lg">
<Users className="w-5 h-5 text-primary" />
@ -588,9 +543,7 @@ export const TabloPage = () => {
<p className="text-sm font-medium text-muted-foreground">
{t("pages:tablo.kpis.todo")}
</p>
<p className="text-2xl font-bold text-foreground mt-1">
{kpis.todoCount}
</p>
<p className="text-2xl font-bold text-foreground mt-1">{kpis.todoCount}</p>
</div>
<div className="p-2 bg-muted rounded-lg">
<ListTodo className="w-5 h-5 text-muted-foreground" />
@ -622,9 +575,7 @@ export const TabloPage = () => {
<p className="text-sm font-medium text-muted-foreground">
{t("pages:tablo.kpis.done")}
</p>
<p className="text-2xl font-bold text-foreground mt-1">
{kpis.doneCount}
</p>
<p className="text-2xl font-bold text-foreground mt-1">{kpis.doneCount}</p>
</div>
<div className="p-2 bg-secondary/50 rounded-lg">
<CheckCircle2 className="w-5 h-5 text-secondary-foreground" />
@ -656,9 +607,7 @@ export const TabloPage = () => {
<p className="text-sm font-medium text-muted-foreground">
{t("pages:tablo.kpis.admin")}
</p>
<p className="text-2xl font-bold text-foreground mt-1">
{kpis.adminCount}
</p>
<p className="text-2xl font-bold text-foreground mt-1">{kpis.adminCount}</p>
</div>
<div className="p-2 bg-primary/10 rounded-lg">
<Shield className="w-5 h-5 text-primary" />
@ -673,9 +622,7 @@ export const TabloPage = () => {
<p className="text-sm font-medium text-muted-foreground">
{t("pages:tablo.kpis.guest")}
</p>
<p className="text-2xl font-bold text-foreground mt-1">
{kpis.guestCount}
</p>
<p className="text-2xl font-bold text-foreground mt-1">{kpis.guestCount}</p>
</div>
<div className="p-2 bg-muted rounded-lg">
<Users className="w-5 h-5 text-muted-foreground" />
@ -710,8 +657,7 @@ export const TabloPage = () => {
<EmptyHeader>
<EmptyTitle>{t("pages:tablo.emptyState.title")}</EmptyTitle>
<EmptyDescription>
{filterType === "all" &&
t("pages:tablo.emptyState.description")}
{filterType === "all" && t("pages:tablo.emptyState.description")}
</EmptyDescription>
</EmptyHeader>
{filterType === "all" && (
@ -735,10 +681,7 @@ export const TabloPage = () => {
{/* Create Tablo Modal */}
{isCreateModalOpen && (
<CreateTabloModal
onClose={closeCreateModal}
onCreate={createNewTablo}
/>
<CreateTabloModal onClose={closeCreateModal} onCreate={createNewTablo} />
)}
{/* Delete Tablo Modal */}

View file

@ -1,6 +1,6 @@
import { LoadingSpinner } from "@ui/components/LoadingSpinner";
import { cn } from "@xtablo/shared";
import type { UserTablo } from "@xtablo/shared/types/tablos.types";
import { LoadingSpinner } from "@ui/components/LoadingSpinner";
import {
CalendarIcon,
Compass,
@ -32,17 +32,28 @@ import { useCreateTablo, useDeleteTablo, useTablosList } from "../hooks/tablos";
function getTabloIcon(color: string | null | undefined) {
switch (color) {
case "bg-blue-500": return Zap;
case "bg-green-500": return Leaf;
case "bg-purple-500": return Gem;
case "bg-red-500": return Flame;
case "bg-yellow-500": return Star;
case "bg-indigo-500": return Compass;
case "bg-pink-500": return Heart;
case "bg-teal-500": return Waves;
case "bg-orange-500": return Sun;
case "bg-cyan-500": return Sparkles;
default: return FolderIcon;
case "bg-blue-500":
return Zap;
case "bg-green-500":
return Leaf;
case "bg-purple-500":
return Gem;
case "bg-red-500":
return Flame;
case "bg-yellow-500":
return Star;
case "bg-indigo-500":
return Compass;
case "bg-pink-500":
return Heart;
case "bg-teal-500":
return Waves;
case "bg-orange-500":
return Sun;
case "bg-cyan-500":
return Sparkles;
default:
return FolderIcon;
}
}
@ -69,13 +80,15 @@ function getStatusConfig(status: string) {
case "done":
return {
label: "Terminé",
badgeClass: "bg-green-50 text-green-600 border border-green-200 dark:bg-green-950/30 dark:text-green-400 dark:border-green-800",
badgeClass:
"bg-green-50 text-green-600 border border-green-200 dark:bg-green-950/30 dark:text-green-400 dark:border-green-800",
progress: 100,
};
default:
return {
label: "À faire",
badgeClass: "bg-blue-50 text-blue-600 border border-blue-200 dark:bg-blue-950/30 dark:text-blue-400 dark:border-blue-800",
badgeClass:
"bg-blue-50 text-blue-600 border border-blue-200 dark:bg-blue-950/30 dark:text-blue-400 dark:border-blue-800",
progress: 0,
};
}
@ -118,7 +131,10 @@ function TabloCard({
<button
type="button"
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
onClick={(e) => { e.stopPropagation(); onDelete(tablo.id); }}
onClick={(e) => {
e.stopPropagation();
onDelete(tablo.id);
}}
>
<Trash2Icon className="w-4 h-4" />
</button>
@ -152,18 +168,28 @@ function TabloCard({
{/* Progress */}
<div className="mb-4">
<div className="flex justify-between items-center mb-2">
<span className="text-sm text-gray-600 dark:text-gray-400">{t("tablo.card.progress")} :</span>
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">{progress}%</span>
<span className="text-sm text-gray-600 dark:text-gray-400">
{t("tablo.card.progress")} :
</span>
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
{progress}%
</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div className="bg-green-500 h-2 rounded-full transition-all" style={{ width: `${progress}%` }} />
<div
className="bg-green-500 h-2 rounded-full transition-all"
style={{ width: `${progress}%` }}
/>
</div>
</div>
{/* Footer */}
<div className="pt-4 border-t border-dashed border-[#D0D5DD] dark:border-gray-600">
<span className="text-sm text-gray-500 dark:text-gray-400">
Créé le <span className="font-semibold text-gray-900 dark:text-gray-100">{formatDate(tablo.created_at)}</span>
Créé le{" "}
<span className="font-semibold text-gray-900 dark:text-gray-100">
{formatDate(tablo.created_at)}
</span>
</span>
</div>
</div>
@ -204,11 +230,15 @@ function TabloRow({
<TabloIcon className={cn("w-4 h-4", iconColor)} />
)}
</div>
<span className="font-medium text-gray-900 dark:text-gray-100 truncate">{tablo.name}</span>
<span className="font-medium text-gray-900 dark:text-gray-100 truncate">
{tablo.name}
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={cn("px-3 py-1 rounded-full text-sm font-medium", badgeClass)}>{label}</span>
<span className={cn("px-3 py-1 rounded-full text-sm font-medium", badgeClass)}>
{label}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-gray-400">
<div className="flex items-center gap-1.5">
@ -219,16 +249,24 @@ function TabloRow({
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-2 min-w-[80px]">
<div className="bg-green-500 h-2 rounded-full transition-all" style={{ width: `${progress}%` }} />
<div
className="bg-green-500 h-2 rounded-full transition-all"
style={{ width: `${progress}%` }}
/>
</div>
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100 w-8 text-right">{progress}%</span>
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100 w-8 text-right">
{progress}%
</span>
</div>
</td>
<td className="px-6 py-4 text-right">
<button
type="button"
className="text-gray-400 hover:text-red-500 dark:text-gray-500 dark:hover:text-red-400 transition-colors p-1 rounded"
onClick={(e) => { e.stopPropagation(); onDelete(tablo.id); }}
onClick={(e) => {
e.stopPropagation();
onDelete(tablo.id);
}}
>
<Trash2Icon className="w-4 h-4" />
</button>
@ -351,7 +389,9 @@ export function TablosPage() {
<div className="w-16 h-16 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center mb-4">
<Grid3x3Icon className="w-8 h-8 text-gray-400" />
</div>
<p className="text-gray-500 dark:text-gray-400 text-lg font-medium">Aucun projet trouvé</p>
<p className="text-gray-500 dark:text-gray-400 text-lg font-medium">
Aucun projet trouvé
</p>
<p className="text-gray-400 dark:text-gray-500 text-sm mt-1">
{searchQuery ? "Essayez un autre terme de recherche" : "Créez votre premier projet"}
</p>
@ -372,10 +412,18 @@ export function TablosPage() {
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-800/80 border-b border-[#EAECF0] dark:border-gray-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">Projet</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">Statut</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">Créé le</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">Progression</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">
Projet
</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">
Statut
</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">
Créé le
</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">
Progression
</th>
<th className="px-6 py-3 w-12" />
</tr>
</thead>

View file

@ -114,15 +114,13 @@ export function TasksPage() {
const [statusFilter, setStatusFilter] = useState<TaskStatus>("all");
const [assigneeFilter, setAssigneeFilter] = useState<string>("all");
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
const [taskModalInitialDueDate, setTaskModalInitialDueDate] = useState<
Date | undefined
>(undefined);
const [taskModalInitialDueDate, setTaskModalInitialDueDate] = useState<Date | undefined>(
undefined
);
const searchQuery = searchParams.get("q") ?? "";
// Get view mode from URL params, default to "kanban"
const viewMode =
(searchParams.get("view") as "kanban" | "aggregated" | "roadmap") ||
"kanban";
const viewMode = (searchParams.get("view") as "kanban" | "aggregated" | "roadmap") || "kanban";
// Function to update view mode in URL
const setViewMode = (mode: "kanban" | "aggregated" | "roadmap") => {
@ -169,9 +167,7 @@ export function TasksPage() {
} else if (assigneeFilter === "unassigned") {
filtered = filtered.filter((task) => !task.assignee_id);
} else {
filtered = filtered.filter(
(task) => task.assignee_id === assigneeFilter,
);
filtered = filtered.filter((task) => task.assignee_id === assigneeFilter);
}
}
@ -180,20 +176,12 @@ export function TasksPage() {
const q = searchQuery.toLowerCase();
filtered = filtered.filter(
(task) =>
task.title?.toLowerCase().includes(q) ||
task.description?.toLowerCase().includes(q),
task.title?.toLowerCase().includes(q) || task.description?.toLowerCase().includes(q)
);
}
return filtered;
}, [
allTasks,
selectedTabloId,
statusFilter,
assigneeFilter,
user.id,
searchQuery,
]);
}, [allTasks, selectedTabloId, statusFilter, assigneeFilter, user.id, searchQuery]);
// Initialize Kanban columns from filtered tasks
const columns = useMemo((): KanbanColumn[] => {
@ -264,7 +252,7 @@ export function TasksPage() {
const handleDrop = (
e: React.DragEvent,
targetStatus: "todo" | "in_progress" | "in_review" | "done",
targetStatus: "todo" | "in_progress" | "in_review" | "done"
) => {
e.preventDefault();
const taskId = e.dataTransfer.getData("taskId");
@ -323,23 +311,18 @@ export function TasksPage() {
type="button"
disabled={tab.disabled}
onClick={() =>
!tab.disabled &&
setViewMode(tab.id as "kanban" | "aggregated" | "roadmap")
!tab.disabled && setViewMode(tab.id as "kanban" | "aggregated" | "roadmap")
}
className={twMerge(
"flex items-center gap-2 pb-3 px-1 text-sm font-semibold transition-colors border-b-2",
isActive
? "text-purple-600 border-purple-600 dark:text-purple-400 dark:border-purple-400"
: "text-[#667085] border-transparent hover:text-gray-900 dark:hover:text-gray-100",
tab.disabled && "cursor-not-allowed",
tab.disabled && "cursor-not-allowed"
)}
>
<tab.icon
className={twMerge("w-4 h-4", tab.disabled && "opacity-40")}
/>
<span className={tab.disabled ? "opacity-40" : ""}>
{tab.label}
</span>
<tab.icon className={twMerge("w-4 h-4", tab.disabled && "opacity-40")} />
<span className={tab.disabled ? "opacity-40" : ""}>{tab.label}</span>
{"comingSoon" in tab && tab.comingSoon && (
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 leading-none">
Bientôt
@ -354,10 +337,7 @@ export function TasksPage() {
<div className="flex flex-col md:flex-row md:items-center md:justify-end gap-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="w-full md:w-auto gap-2 bg-transparent"
>
<Button variant="outline" className="w-full md:w-auto gap-2 bg-transparent">
<Settings2Icon className="w-4 h-4" />
Filtrer
</Button>
@ -381,7 +361,7 @@ export function TasksPage() {
<div
className={twMerge(
"w-2 h-2 rounded-full shrink-0",
tablo.color || "bg-gray-400",
tablo.color || "bg-gray-400"
)}
/>
{tablo.name}
@ -484,9 +464,7 @@ export function TasksPage() {
{/* Column header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-2">
<CircleIcon
className={`w-5 h-5 ${columnIconColor}`}
/>
<CircleIcon className={`w-5 h-5 ${columnIconColor}`} />
<h2 className="font-semibold text-gray-800 dark:text-gray-100">
{column.title}
</h2>
@ -525,8 +503,7 @@ export function TasksPage() {
const isOverdue =
task.due_date &&
task.status !== "done" &&
new Date(task.due_date) <
new Date(new Date().toDateString());
new Date(task.due_date) < new Date(new Date().toDateString());
return (
<div
@ -551,10 +528,7 @@ export function TasksPage() {
<EllipsisVerticalIcon className="w-4 h-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-48"
>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
@ -564,17 +538,8 @@ export function TasksPage() {
Ouvrir la tâche
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>
Déplacer vers
</DropdownMenuLabel>
{(
[
"todo",
"in_progress",
"in_review",
"done",
] as const
)
<DropdownMenuLabel>Déplacer vers</DropdownMenuLabel>
{(["todo", "in_progress", "in_review", "done"] as const)
.filter((s) => s !== task.status)
.map((s) => (
<DropdownMenuItem
@ -600,7 +565,7 @@ export function TasksPage() {
"flex items-center text-xs mb-3",
isOverdue
? "text-red-500"
: "text-gray-500 dark:text-gray-400",
: "text-gray-500 dark:text-gray-400"
)}
>
<CalendarIcon className="w-3.5 h-3.5 mr-1.5" />
@ -611,27 +576,17 @@ export function TasksPage() {
{/* Tablo row */}
{taskWithTablo.tablos &&
(() => {
const TabloIcon = getTabloIcon(
taskWithTablo.tablos.color,
);
const iconColor = getTabloIconColor(
taskWithTablo.tablos.color,
);
const TabloIcon = getTabloIcon(taskWithTablo.tablos.color);
const iconColor = getTabloIconColor(taskWithTablo.tablos.color);
return (
<div className="flex items-center mb-3 border-b border-dashed border-[#D0D5DD] dark:border-gray-600 pb-3">
<div
className={twMerge(
"w-5 h-5 rounded-[5px] mr-2 flex items-center justify-center shrink-0",
taskWithTablo.tablos.color ||
"bg-gray-400",
taskWithTablo.tablos.color || "bg-gray-400"
)}
>
<TabloIcon
className={twMerge(
"w-3 h-3",
iconColor,
)}
/>
<TabloIcon className={twMerge("w-3 h-3", iconColor)} />
</div>
<span className="text-xs text-gray-600 dark:text-gray-400 truncate">
{taskWithTablo.tablos.name}
@ -644,12 +599,10 @@ export function TasksPage() {
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3 text-gray-500 dark:text-gray-400">
<div className="flex items-center text-xs">
<MessageSquareIcon className="w-3.5 h-3.5 mr-1" />
0
<MessageSquareIcon className="w-3.5 h-3.5 mr-1" />0
</div>
<div className="flex items-center text-xs">
<PaperclipIcon className="w-3.5 h-3.5 mr-1" />
0
<PaperclipIcon className="w-3.5 h-3.5 mr-1" />0
</div>
</div>
@ -664,9 +617,7 @@ export function TasksPage() {
/>
) : (
<div className="w-6 h-6 rounded-full bg-purple-500 border-2 border-white dark:border-gray-800 flex items-center justify-center text-white text-[10px] font-medium">
{task.assignee_name
?.charAt(0)
.toUpperCase() || (
{task.assignee_name?.charAt(0).toUpperCase() || (
<UserIcon className="w-3 h-3" />
)}
</div>
@ -728,9 +679,7 @@ export function TasksPage() {
{/* Column header */}
<div className="px-4 md:px-6 py-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<CircleIcon
className={`w-5 h-5 ${columnIconColor}`}
/>
<CircleIcon className={`w-5 h-5 ${columnIconColor}`} />
<h3 className="font-semibold text-gray-900 dark:text-gray-100">
{column.title}
</h3>
@ -801,26 +750,20 @@ export function TasksPage() {
<td className="px-4 md:px-6 py-3">
{taskWithTablo.tablos ? (
(() => {
const TabloIcon = getTabloIcon(
taskWithTablo.tablos.color,
);
const TabloIcon = getTabloIcon(taskWithTablo.tablos.color);
const iconColor = getTabloIconColor(
taskWithTablo.tablos.color,
taskWithTablo.tablos.color
);
return (
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<div
className={twMerge(
"w-5 h-5 rounded-[4px] shrink-0 flex items-center justify-center",
taskWithTablo.tablos.color ||
"bg-gray-400",
taskWithTablo.tablos.color || "bg-gray-400"
)}
>
<TabloIcon
className={twMerge(
"w-3 h-3",
iconColor,
)}
className={twMerge("w-3 h-3", iconColor)}
/>
</div>
<span className="truncate">
@ -830,9 +773,7 @@ export function TasksPage() {
);
})()
) : (
<span className="text-sm text-gray-400">
</span>
<span className="text-sm text-gray-400"></span>
)}
</td>
@ -850,26 +791,21 @@ export function TasksPage() {
"flex items-center gap-1 text-sm",
dueDateOverdue
? "text-red-500"
: "text-gray-600 dark:text-gray-400",
: "text-gray-600 dark:text-gray-400"
)}
>
<CalendarIcon className="w-3.5 h-3.5" />
<span>
{new Intl.DateTimeFormat(
"fr-FR",
{
day: "2-digit",
month: "short",
},
).format(new Date(task.due_date))}
{new Intl.DateTimeFormat("fr-FR", {
day: "2-digit",
month: "short",
}).format(new Date(task.due_date))}
</span>
</div>
);
})()
) : (
<span className="text-sm text-gray-400">
</span>
<span className="text-sm text-gray-400"></span>
)}
</td>
@ -889,9 +825,7 @@ export function TasksPage() {
title={task.assignee_name || ""}
className="w-6 h-6 rounded-full bg-purple-200 dark:bg-purple-900 text-purple-700 dark:text-purple-300 flex items-center justify-center text-xs font-semibold border border-white dark:border-gray-800"
>
{task.assignee_name
?.charAt(0)
.toUpperCase() || (
{task.assignee_name?.charAt(0).toUpperCase() || (
<UserIcon className="w-3 h-3" />
)}
</div>
@ -916,10 +850,7 @@ export function TasksPage() {
<EllipsisVerticalIcon className="w-4 h-4 text-gray-400" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-48"
>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
@ -929,17 +860,8 @@ export function TasksPage() {
Ouvrir la tâche
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>
Déplacer vers
</DropdownMenuLabel>
{(
[
"todo",
"in_progress",
"in_review",
"done",
] as const
)
<DropdownMenuLabel>Déplacer vers</DropdownMenuLabel>
{(["todo", "in_progress", "in_review", "done"] as const)
.filter((s) => s !== task.status)
.map((s) => (
<DropdownMenuItem

View file

@ -13,7 +13,9 @@ export interface EtapeProgressStats {
export function getEtapeProgressStats(etapes: Etape[]): EtapeProgressStats {
const total = etapes.length;
const done = etapes.filter((etape) => etape.status === "done").length;
const started = etapes.filter((etape) => STARTED_ETAPE_STATUSES.has(etape.status ?? "todo")).length;
const started = etapes.filter((etape) =>
STARTED_ETAPE_STATUSES.has(etape.status ?? "todo")
).length;
if (total === 0) {
return {

File diff suppressed because one or more lines are too long

View file

@ -48,14 +48,14 @@ export type {
UserSubscriptionStatus,
} from "./stripe.types.js";
// ============================================================================
// Tablo Types
// ============================================================================
export type { CreateTablo, Tablo, TabloInsert, TabloUpdate, UserTablo } from "./tablos.types.js";
// ============================================================================
// Tablo Data Types (Files and Folders)
// ============================================================================
export type { TabloFolder, TabloFoldersMetadata } from "./tablo-data.types.js";
// ============================================================================
// Tablo Types
// ============================================================================
export type { CreateTablo, Tablo, TabloInsert, TabloUpdate, UserTablo } from "./tablos.types.js";
// ============================================================================
// Utility Types
// ============================================================================
export type {

View file

@ -20,4 +20,3 @@ export interface TabloFoldersMetadata {
folders: TabloFolder[];
version: number;
}