diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..4e7e331
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,253 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Common Commands
+
+### Development
+```bash
+pnpm install # Install dependencies
+pnpm dev # Run all apps in development
+pnpm dev:main # Run main app only (port 5173)
+pnpm dev:external # Run external booking widget (port 5174)
+pnpm dev:api # Run API server (port 8080)
+```
+
+### Building
+```bash
+pnpm build # Build all apps
+pnpm build:apps # Build apps only (packages are source-only)
+pnpm build:staging # Build main app for staging
+pnpm build:prod # Build main app for production
+```
+
+### Testing
+```bash
+pnpm test # Run all tests
+pnpm test:watch # Run tests in watch mode
+pnpm test:api # Run API tests only
+cd apps/main && pnpm test # Run tests for specific package
+```
+
+### Quality Checks
+```bash
+pnpm lint # Check all packages with Biome
+pnpm lint:fix # Fix linting issues
+pnpm typecheck # Type check everything
+pnpm format # Format code
+```
+
+### Cleanup
+```bash
+pnpm clean # Clean all build artifacts and caches
+```
+
+## Architecture Overview
+
+### Monorepo Structure
+This is a Turborepo-based monorepo with three main apps and shared packages:
+
+- **apps/main** (`@xtablo/main`): Primary authenticated dashboard with tablos, planning, events, chat, and notes
+- **apps/external** (`@xtablo/external`): Public booking widget (embeddable/floating modes)
+- **apps/api** (`@xtablo/api`): Hono-based REST API serving both frontend apps
+- **packages/shared** (`@xtablo/shared`): React contexts, hooks, API client, React Query setup
+- **packages/ui** (`@xtablo/ui`): Radix UI + Tailwind component library
+- **packages/shared-types** (`@xtablo/shared-types`): Pure TypeScript types (zero runtime dependencies)
+
+### Source-Only Packages
+The `@xtablo/shared` and `@xtablo/ui` packages are **source-only** - they export TypeScript directly without a build step. Changes are instantly reflected via Vite's HMR. There's no need to rebuild or watch these packages during development.
+
+## Key Architectural Patterns
+
+### State Management
+1. **React Query** (TanStack Query v5): Primary tool for server state
+ - 5-minute default cache time
+ - Hierarchical query keys: `["tablos"]`, `["tablos", id]`, `["tablo-files", tabloId]`
+ - Targeted cache invalidation on mutations
+
+2. **Zustand**: Global client state, especially user context
+ - User fetched via React Query, stored in Zustand for app-wide access
+ - Two hooks: `useUser()` (throws if no session) and `useMaybeUser()` (returns null)
+
+### Authentication & Sessions
+- **Supabase Auth** with JWT tokens
+- `SessionContext` listens to `supabase.auth.onAuthStateChange()`
+- API validates JWT from Authorization header
+- Passwordless flow generates temporary accounts (`is_temporary: true`)
+- Protected routes use `useMaybeUser()` to check authentication
+
+### API Architecture
+- **Framework**: Hono (edge-runtime compatible)
+- **Middleware Manager**: Singleton pattern (`initializeMiddleware()` called once, reused)
+- **Router Order**: Public routes first → middleware applied (supabase → stream/r2/email) → authenticated routes
+- **Key Routers**:
+ - `public.ts`: Unauthenticated endpoints
+ - `authRouter.ts`: Requires authentication
+ - `maybeAuthRouter.ts`: Optional authentication
+ - `tablo.ts`, `tablo_data.ts`: Core business logic
+ - `stripe.ts`: Payment webhooks and operations
+
+### Database & Types
+- **Supabase PostgreSQL** with auto-generated types
+- Generate types: `npx supabase gen types typescript > packages/shared-types/src/database.types.ts`
+- **Type hierarchy**: `database.types.ts` (auto-generated) → domain types (nulls removed) → API responses
+- **@xtablo/shared-types**: Zero-dependency package for all types
+- Frontend uses direct Supabase client: `supabase.from("table").select()`
+- API uses service role key to bypass RLS when needed
+
+### Data Fetching Patterns
+1. **Direct Supabase queries**: `useQuery()` → `supabase.from("table").select().eq(...)`
+2. **API calls**: `useQuery()` → `api.get("/api/v1/...")` with Bearer token
+3. **File operations**: Specialized hooks (`useUploadTabloFile`, `useDeleteTabloFile`) with automatic cache invalidation
+
+### Routing
+- **Main app**: Two route sets
+ - `publicRoutes`: Auth pages, legal pages (outside UserStoreProvider)
+ - `routes`: Protected app routes (inside UserStoreProvider)
+- **Protected routes**: Component checks `useMaybeUser()`, redirects to landing if unauthenticated
+- **External app**: Query params control mode (`?mode=embed&eventTypeId=...`)
+
+### Component Organization
+- Modals: `*Modal.tsx`
+- Sections: `*Section.tsx`
+- Cards: `*Card.tsx`
+- Tests co-located: `*.test.tsx`
+- Shared UI in `@xtablo/ui`
+- Business logic hooks in `@xtablo/shared`
+
+## External Integrations
+
+### Stream Chat
+- Chat provider wraps routes
+- Users authenticate with `streamToken` from API
+
+### Stripe
+- Webhooks: `/api/v1/stripe-webhook`
+- Sync engine keeps Supabase ↔ Stripe in sync
+- See `docs/STRIPE_*.md` for detailed documentation
+
+### Storage
+- Files stored in Cloudflare R2 (S3-compatible)
+- AWS S3 SDK for uploads/downloads
+- File metadata in database
+
+### Observability
+- **Frontend**: Datadog RUM
+- **API**: dd-trace for APM
+
+## Build & Deployment
+
+### Turborepo
+- Caches build outputs intelligently
+- Tasks run in parallel when possible
+- Filter specific apps: `turbo build --filter=@xtablo/main`
+
+### Deployments
+- **Main app**: Cloudflare Workers (Vite build)
+- **API**: Google Cloud Run (TypeScript compiled)
+- **Config**: Centralized in API, secrets from Google Secret Manager
+
+### Environment-Specific Builds
+```bash
+pnpm build:staging # Uses .env.staging
+pnpm build:prod # Uses .env.production
+```
+
+## Development Conventions
+
+### Query Keys
+Use hierarchical naming for proper cache invalidation:
+```typescript
+["tablos"] // List of tablos
+["tablos", tabloId] // Single tablo
+["tablo-files", tabloId] // Files for a tablo
+```
+
+### Hook Patterns
+All hooks return consistent shapes:
+```typescript
+// Queries
+const { data, isLoading, error } = useMyQuery()
+
+// Mutations
+const { mutate, isPending } = useMyMutation()
+```
+
+### Error Handling
+- Errors display as toast messages via `toast.add()`
+- Use friendly, user-facing error messages
+- Log technical details for debugging
+
+### Loading States
+Three levels of loading feedback:
+1. Route level: `ProtectedRoute` shows spinner
+2. Feature level: React Query `isLoading`
+3. Action level: Button `disabled` during mutation
+
+### Type Safety
+- No circular dependencies between packages
+- API only imports from `@xtablo/shared-types`
+- Frontend apps can import from all shared packages
+
+## Adding New Features
+
+1. **Define types** in `@xtablo/shared-types` or update database schema
+2. **Add API endpoint** in `apps/api/src/routers/`
+3. **Create React Query hook** in shared or app-specific hooks
+4. **Build UI component** using the hook and `@xtablo/ui` components
+5. **Add route** to `apps/main/src/lib/routes.tsx` if needed
+
+## Testing Strategy
+
+### API Tests
+- Vitest with test environment setup
+- Mock Supabase client for database operations
+- Test middleware and routers independently
+- See `docs/API_TESTS.md` and `docs/MIDDLEWARE_TESTS.md`
+
+### Frontend Tests
+- Vitest + React Testing Library + happy-dom
+- Test components in isolation
+- Mock React Query hooks for integration tests
+- Run with `pnpm test` or `pnpm test:watch`
+
+## Important Notes
+
+### Type Generation
+After database schema changes, regenerate types:
+```bash
+npx supabase gen types typescript > packages/shared-types/src/database.types.ts
+```
+
+### Cache Issues
+If you encounter stale builds or weird caching:
+```bash
+pnpm clean
+rm -rf node_modules/.cache
+pnpm install
+pnpm build
+```
+
+### IDE TypeScript
+If VS Code shows type errors but build works:
+- Cmd+Shift+P → "TypeScript: Restart TS Server"
+- Check `pnpm typecheck` passes
+- Ensure package TypeScript configs are valid
+
+### Docker Development
+The project includes Docker configurations for deployment:
+- See `docs/DOCKER_*.md` for Docker build optimization
+- API Dockerfile uses multi-stage builds with pnpm
+- Cloud Build configurations in `docs/CLOUD_BUILD_*.md`
+
+## Documentation
+
+Extensive documentation available in `/docs`:
+- `DEVELOPMENT.md`: Comprehensive development guide
+- `API_*.md`: API testing and integration
+- `STRIPE_*.md`: Stripe integration details
+- `AUTH_*.md`: Authentication patterns
+- `DOCKER_*.md`: Docker and deployment
+- `CLOUD_BUILD_*.md`: GCP Cloud Build setup
+
+For questions about architecture decisions or detailed implementation notes, check the docs folder first.
diff --git a/apps/main/package.json b/apps/main/package.json
index a862a2c..19ac56c 100644
--- a/apps/main/package.json
+++ b/apps/main/package.json
@@ -96,6 +96,7 @@
"@types/react-router-dom": "^5.3.3",
"@typescript/native-preview": "7.0.0-dev.20251010.1",
"@xtablo/shared": "workspace:*",
+ "@xtablo/shared-types": "workspace:*",
"@xtablo/ui": "workspace:*",
"ag-grid-community": "^33.2.1",
"ag-grid-react": "^33.2.1",
diff --git a/apps/main/src/components/TabloMembersSection.test.tsx b/apps/main/src/components/TabloMembersSection.test.tsx
new file mode 100644
index 0000000..010b499
--- /dev/null
+++ b/apps/main/src/components/TabloMembersSection.test.tsx
@@ -0,0 +1,86 @@
+import { describe, expect, it, vi } from "vitest";
+import { renderWithProviders } from "../utils/testHelpers";
+import { TabloMembersSection } from "./TabloMembersSection";
+
+vi.mock("../hooks/tablos", () => ({
+ useTabloMembers: () => ({
+ data: [
+ {
+ id: "user-1",
+ name: "John Doe",
+ email: "john@example.com",
+ is_admin: true,
+ },
+ {
+ id: "user-2",
+ name: "Jane Smith",
+ email: "jane@example.com",
+ is_admin: false,
+ },
+ ],
+ }),
+}));
+
+vi.mock("../hooks/tablo_invites", () => ({
+ usePendingTabloInvitesByTablo: () => ({
+ data: [],
+ }),
+}));
+
+vi.mock("../hooks/invite", () => ({
+ useInviteUser: () => ({
+ mutate: vi.fn(),
+ isPending: false,
+ }),
+}));
+
+describe("TabloMembersSection", () => {
+ const mockTablo = {
+ id: "test-tablo-id",
+ name: "Test Tablo",
+ color: "bg-blue-500",
+ user_id: "test-user-id",
+ access_level: "admin",
+ is_admin: true,
+ created_at: "2024-01-01T00:00:00Z",
+ deleted_at: "2024-01-01T00:00:00Z",
+ position: 0,
+ status: "active",
+ image: null,
+ };
+
+ it("renders without crashing for admin", () => {
+ const { container } = renderWithProviders(
+
+ );
+ expect(container).toBeInTheDocument();
+ });
+
+ it("renders without crashing for non-admin", () => {
+ const { container } = renderWithProviders(
+
+ );
+ expect(container).toBeInTheDocument();
+ });
+
+ it("shows invite section for admin", () => {
+ const { getByPlaceholderText } = renderWithProviders(
+
+ );
+ expect(getByPlaceholderText("Email de l'utilisateur à inviter")).toBeInTheDocument();
+ });
+
+ it("does not show invite section for non-admin", () => {
+ const { queryByPlaceholderText } = renderWithProviders(
+
+ );
+ expect(queryByPlaceholderText("Email de l'utilisateur à inviter")).not.toBeInTheDocument();
+ });
+
+ it("shows members list for all users", () => {
+ const { getByText } = renderWithProviders(
+
+ );
+ expect(getByText("Membres du tablo")).toBeInTheDocument();
+ });
+});
diff --git a/apps/main/src/components/TabloMembersSection.tsx b/apps/main/src/components/TabloMembersSection.tsx
new file mode 100644
index 0000000..2c076fd
--- /dev/null
+++ b/apps/main/src/components/TabloMembersSection.tsx
@@ -0,0 +1,164 @@
+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 { usePendingTabloInvitesByTablo } from "src/hooks/tablo_invites";
+import { useInviteUser } from "../hooks/invite";
+import { useTabloMembers } from "../hooks/tablos";
+import { useUser } from "../providers/UserStoreProvider";
+
+interface TabloMembersSectionProps {
+ tablo: UserTablo;
+ isAdmin: boolean;
+}
+
+export const TabloMembersSection = ({ tablo, isAdmin }: TabloMembersSectionProps) => {
+ const currentUser = useUser();
+ const { data: members } = useTabloMembers(tablo.id);
+ const { data: pendingInvites } = usePendingTabloInvitesByTablo(tablo.id);
+
+ const [inviteEmail, setInviteEmail] = useState("");
+ const { mutate: inviteUser, isPending: isInvitingUser } = useInviteUser();
+
+ const filteredMembers = members?.filter(
+ (member) => !pendingInvites?.some((invite) => invite.invited_email === member.email)
+ );
+
+ const handleSendInvite = () => {
+ if (inviteEmail.trim()) {
+ inviteUser({ email: inviteEmail, tablo_id: tablo.id });
+ setInviteEmail("");
+ }
+ };
+
+ const isEmailValid = (email: string): boolean => {
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ return emailRegex.test(email);
+ };
+
+ return (
+
+
+
+
+
+ Membres
+
+
Gérez les membres de votre tablo
+
+
+
+ {/* Invite User Section - Only for Admins */}
+ {isAdmin && (
+ <>
+
+
Inviter un utilisateur
+
+
setInviteEmail(e.target.value)}
+ placeholder="Email de l'utilisateur à inviter"
+ className="flex-1 px-3 py-2 border border-input rounded-md shadow-sm placeholder-muted-foreground focus:outline-none focus:ring-primary focus:border-primary bg-background text-foreground"
+ />
+ {isInvitingUser ? (
+
+ ) : (
+
+ )}
+
+
+
+ {/* Pending Invites Section - Only for Admins */}
+ {pendingInvites && pendingInvites.length > 0 && (
+
+
+ Invitations en attente
+
+ ({pendingInvites.length})
+
+
+
+
+ {pendingInvites.map((invite) => (
+
+
+
+
+ {invite.invited_email}
+
+ (En attente)
+
+
+ ))}
+
+
+ )}
+ >
+ )}
+
+ {/* Members List - Visible for All Users */}
+
+
+ Membres du tablo
+ {filteredMembers && (
+
+ ({filteredMembers.length})
+
+ )}
+
+
+
+ {filteredMembers && filteredMembers.length > 0 ? (
+ filteredMembers.map((member, index) => (
+
+
+ {member.name.charAt(0).toUpperCase()}
+
+
+ {member.name}
+ {member.is_admin ? (
+
+ {member.id === currentUser?.id ? "(Vous, Admin)" : "(Admin)"}
+
+ ) : (
+
+ {member.id === currentUser?.id ? "(Vous, Invité)" : "(Invité)"}
+
+ )}
+
+
+ ))
+ ) : (
+
Aucun membre trouvé
+ )}
+
+
+
+ );
+};
diff --git a/apps/main/src/components/TabloSettingsSection.tsx b/apps/main/src/components/TabloSettingsSection.tsx
index f837fc3..10fa567 100644
--- a/apps/main/src/components/TabloSettingsSection.tsx
+++ b/apps/main/src/components/TabloSettingsSection.tsx
@@ -2,10 +2,6 @@ import { TabloUpdate, UserTablo } from "@xtablo/shared/types/tablos.types";
import { Button } from "@xtablo/ui/components/button";
import { Input } from "@xtablo/ui/components/input";
import { useEffect, useRef, useState } from "react";
-import { usePendingTabloInvitesByTablo } from "src/hooks/tablo_invites";
-import { useInviteUser } from "../hooks/invite";
-import { useTabloMembers } from "../hooks/tablos";
-import { useUser } from "../providers/UserStoreProvider";
import { ClickOutside } from "./ClickOutside";
import { ImageColorPicker } from "./ImageColorPicker";
import { StatusPicker } from "./StatusPicker";
@@ -19,23 +15,13 @@ interface TabloSettingsSectionProps {
}
export const TabloSettingsSection = ({ tablo, isAdmin, onEdit }: TabloSettingsSectionProps) => {
- const currentUser = useUser();
const [editData, setEditData] = useState(tablo);
const [isEditingName, setIsEditingName] = useState(false);
const [creationMode, setCreationMode] = useState<"image" | "color">("color");
const [selectedColor, setSelectedColor] = useState(tablo.color || "bg-blue-500");
- const { data: members } = useTabloMembers(tablo.id);
- const { data: pendingInvites } = usePendingTabloInvitesByTablo(tablo.id);
-
- const [inviteEmail, setInviteEmail] = useState("");
- const { mutate: inviteUser, isPending: isInvitingUser } = useInviteUser();
const nameInputRef = useRef(null);
- const filteredMembers = members?.filter(
- (member) => !pendingInvites?.some((invite) => invite.invited_email === member.email)
- );
-
useEffect(() => {
setEditData(tablo);
setSelectedColor(tablo.color || "bg-blue-500");
@@ -61,18 +47,6 @@ export const TabloSettingsSection = ({ tablo, isAdmin, onEdit }: TabloSettingsSe
}
};
- const handleSendInvite = () => {
- if (inviteEmail.trim()) {
- inviteUser({ email: inviteEmail, tablo_id: tablo.id });
- setInviteEmail("");
- }
- };
-
- const isEmailValid = (email: string): boolean => {
- const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
- return emailRegex.test(email);
- };
-
const currentData = editData || tablo;
return (
@@ -191,116 +165,8 @@ export const TabloSettingsSection = ({ tablo, isAdmin, onEdit }: TabloSettingsSe
}
/>
-
- {/* Invite User Section */}
-
-
Inviter un utilisateur
-
-
setInviteEmail(e.target.value)}
- placeholder="Email de l'utilisateur à inviter"
- className="flex-1 px-3 py-2 border border-input rounded-md shadow-sm placeholder-muted-foreground focus:outline-none focus:ring-primary focus:border-primary bg-background text-foreground"
- />
- {isInvitingUser ? (
-
- ) : (
-
- )}
-
-
- {pendingInvites && pendingInvites.length > 0 && (
-
-
- Invitations en attente
-
- ({pendingInvites.length})
-
-
-
-
- {pendingInvites.map((invite) => (
-
-
-
-
- {invite.invited_email}
-
- (En attente)
-
-
- ))}
-
-
- )}
)}
-
- {/* Members List */}
-
-
- Membres
- {filteredMembers && (
-
- ({filteredMembers.length})
-
- )}
-
-
-
- {filteredMembers && filteredMembers.length > 0 ? (
- filteredMembers.map((member, index) => (
-
-
- {member.name.charAt(0).toUpperCase()}
-
-
- {member.name}
- {member.is_admin ? (
-
- {member.id === currentUser?.id ? "(Vous, Admin)" : "(Admin)"}
-
- ) : (
-
- {member.id === currentUser?.id ? "(Vous, Invité)" : "(Invité)"}
-
- )}
-
-
- ))
- ) : (
-
Aucun membre trouvé
- )}
-
-
-
- {/* Pending Invites */}
);
};
diff --git a/apps/main/src/components/TabloTasksSection.tsx b/apps/main/src/components/TabloTasksSection.tsx
new file mode 100644
index 0000000..d7a0165
--- /dev/null
+++ b/apps/main/src/components/TabloTasksSection.tsx
@@ -0,0 +1,170 @@
+import { toast } from "@xtablo/shared";
+import { UserTablo } from "@xtablo/shared/types/tablos.types";
+import type { KanbanColumn, KanbanTask, KanbanTaskInsert, TaskStatus } from "@xtablo/shared-types";
+import { ListChecks } from "lucide-react";
+import { useCallback, useEffect, useState } from "react";
+import { useTabloMembers } from "../hooks/tablos";
+import { useCreateTask, useTasksByTablo, useUpdateTaskPositions } from "../hooks/tasks";
+import { KanbanBoard } from "./kanban/KanbanBoard";
+import { TaskModal } from "./kanban/TaskModal";
+
+interface TabloTasksSectionProps {
+ tablo: UserTablo;
+ isAdmin: boolean;
+}
+
+export const TabloTasksSection = ({ tablo }: TabloTasksSectionProps) => {
+ const { data: members = [] } = useTabloMembers(tablo.id);
+ const [columns, setColumns] = useState([]);
+ const [isModalOpen, setIsModalOpen] = useState(false);
+ const [selectedTask, setSelectedTask] = useState(null);
+
+ const { data: tasks } = useTasksByTablo(tablo.id);
+ const { mutate: updateTaskPositions } = useUpdateTaskPositions();
+ const { mutate: createTask } = useCreateTask();
+
+ // Helper functions defined before use
+ const initializeColumns = useCallback((tasks: KanbanTask[]): KanbanColumn[] => {
+ const defaultColumns: KanbanColumn[] = [
+ {
+ id: "todo",
+ title: "À faire",
+ status: "todo",
+ position: 0,
+ tasks: tasks.filter((task) => task.status === "todo"),
+ },
+ {
+ id: "in_progress",
+ title: "En cours",
+ status: "in_progress",
+ position: 1,
+ tasks: tasks.filter((task) => task.status === "in_progress"),
+ },
+ {
+ id: "in_review",
+ title: "Vérification",
+ status: "in_review",
+ position: 2,
+ tasks: tasks.filter((task) => task.status === "in_review"),
+ },
+ {
+ id: "done",
+ title: "Terminé",
+ status: "done",
+ position: 3,
+ tasks: tasks.filter((task) => task.status === "done"),
+ },
+ ];
+ return defaultColumns;
+ }, []);
+
+ useEffect(() => {
+ setColumns(initializeColumns(tasks ?? []));
+ }, [initializeColumns, tasks]);
+
+ const handleAddTask = () => {
+ setSelectedTask(null);
+ setIsModalOpen(true);
+ };
+
+ const handleCreateTask = (taskData: {
+ title: string;
+ description: string;
+ assignee_id?: string;
+ status: TaskStatus;
+ }) => {
+ const newTask: KanbanTaskInsert = {
+ id: `task-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
+ title: taskData.title,
+ description: taskData.description,
+ status: taskData.status,
+ assignee_id: taskData.assignee_id,
+ tablo_id: tablo.id,
+ created_at: new Date().toISOString(),
+ updated_at: new Date().toISOString(),
+ position: 0,
+ };
+
+ createTask(newTask);
+
+ // setColumns((prevColumns) =>
+ // prevColumns.map((column: KanbanColumn) => {
+ // if (column.status === (taskData.status as TaskStatus)) {
+ // return {
+ // ...column,
+ // tasks: [newTask, ...column.tasks],
+ // };
+ // }
+ // return column;
+ // })
+ // );
+
+ toast.add(
+ {
+ title: "Tâche créée",
+ description: `La tâche "${taskData.title}" a été créée avec succès`,
+ type: "success",
+ },
+ { timeout: 3000 }
+ );
+ };
+
+ const handleTaskMove = (taskId: string, newStatus: TaskStatus) => {
+ updateTaskPositions([
+ {
+ id: taskId,
+ position: columns.find((column) => column.status === newStatus)?.position ?? 0,
+ status: newStatus,
+ },
+ ]);
+
+ toast.add(
+ {
+ title: "Tâche déplacée",
+ description: "La tâche a été déplacée avec succès",
+ type: "success",
+ },
+ { timeout: 2000 }
+ );
+ };
+
+ const handleTaskClick = (task: KanbanTask) => {
+ setSelectedTask(task);
+ setIsModalOpen(true);
+ };
+
+ return (
+
+
+
+
+
+ Tâches
+
+
Gérez vos tâches avec un tableau Kanban
+
+
+
+ {/* Kanban Board */}
+
+
+
+
+ {/* Task Create Modal */}
+
setIsModalOpen(false)}
+ members={members}
+ />
+
+ );
+};
diff --git a/apps/main/src/components/kanban/InlineTaskCreate.tsx b/apps/main/src/components/kanban/InlineTaskCreate.tsx
new file mode 100644
index 0000000..6e6cceb
--- /dev/null
+++ b/apps/main/src/components/kanban/InlineTaskCreate.tsx
@@ -0,0 +1,176 @@
+import type { TaskStatus } from "@xtablo/shared-types";
+import { Button } from "@xtablo/ui/components/button";
+import { Input } from "@xtablo/ui/components/input";
+import { Label } from "@xtablo/ui/components/label";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@xtablo/ui/components/select";
+import { Textarea } from "@xtablo/ui/components/textarea";
+import { Plus, X } from "lucide-react";
+import { useEffect, useRef, useState } from "react";
+import type { TabloMember } from "./types";
+
+interface InlineTaskCreateProps {
+ status: TaskStatus;
+ members: TabloMember[];
+ onSubmit: (task: {
+ title: string;
+ description: string;
+ assignee_id?: string;
+ status: TaskStatus;
+ }) => void;
+}
+
+export const InlineTaskCreate = ({ status, members, onSubmit }: InlineTaskCreateProps) => {
+ const [isCreating, setIsCreating] = useState(false);
+ const [title, setTitle] = useState("");
+ const [showAdvanced, setShowAdvanced] = useState(false);
+ const [description, setDescription] = useState("");
+ const [assigneeId, setAssigneeId] = useState("unassigned");
+ const inputRef = useRef(null);
+
+ useEffect(() => {
+ if (isCreating && inputRef.current) {
+ inputRef.current.focus();
+ }
+ }, [isCreating]);
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!title.trim()) return;
+
+ onSubmit({
+ title: title.trim(),
+ description: description.trim(),
+ assignee_id: assigneeId !== "unassigned" ? assigneeId : undefined,
+ status,
+ });
+
+ // Reset form
+ setTitle("");
+ setDescription("");
+ setAssigneeId("unassigned");
+ setShowAdvanced(false);
+ setIsCreating(false);
+ };
+
+ const handleCancel = () => {
+ setTitle("");
+ setDescription("");
+ setAssigneeId("unassigned");
+ setShowAdvanced(false);
+ setIsCreating(false);
+ };
+
+ if (!isCreating) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+};
diff --git a/apps/main/src/components/kanban/KanbanBoard.tsx b/apps/main/src/components/kanban/KanbanBoard.tsx
new file mode 100644
index 0000000..c780ac5
--- /dev/null
+++ b/apps/main/src/components/kanban/KanbanBoard.tsx
@@ -0,0 +1,69 @@
+import type {
+ KanbanColumn as KanbanColumnType,
+ KanbanTask,
+ TaskStatus,
+} from "@xtablo/shared-types";
+import { useState } from "react";
+import { KanbanColumn } from "./KanbanColumn";
+import type { TabloMember } from "./types";
+
+interface KanbanBoardProps {
+ columns: KanbanColumnType[];
+ members: TabloMember[];
+ onTaskClick: (task: KanbanTask) => void;
+ onAddTask: (status: TaskStatus) => void;
+ onAddTaskInline: (task: {
+ title: string;
+ description: string;
+ assignee_id?: string;
+ status: TaskStatus;
+ }) => void;
+ onTaskMove: (taskId: string, newStatus: TaskStatus) => void;
+}
+
+export const KanbanBoard = ({
+ columns,
+ members,
+ onTaskClick,
+ onAddTask,
+ onAddTaskInline,
+ onTaskMove,
+}: KanbanBoardProps) => {
+ const [draggedTask, setDraggedTask] = useState(null);
+
+ const handleDragStart = (e: React.DragEvent, task: KanbanTask) => {
+ setDraggedTask(task);
+ e.dataTransfer.effectAllowed = "move";
+ };
+
+ const handleDragOver = (e: React.DragEvent) => {
+ e.preventDefault();
+ e.dataTransfer.dropEffect = "move";
+ };
+
+ const handleDrop = (e: React.DragEvent, targetStatus: TaskStatus) => {
+ e.preventDefault();
+ if (draggedTask && draggedTask.status !== targetStatus) {
+ onTaskMove(draggedTask.id, targetStatus);
+ }
+ setDraggedTask(null);
+ };
+
+ return (
+
+ {columns.map((column) => (
+
+ ))}
+
+ );
+};
diff --git a/apps/main/src/components/kanban/KanbanColumn.tsx b/apps/main/src/components/kanban/KanbanColumn.tsx
new file mode 100644
index 0000000..7f7c97b
--- /dev/null
+++ b/apps/main/src/components/kanban/KanbanColumn.tsx
@@ -0,0 +1,89 @@
+import type {
+ KanbanColumn as KanbanColumnType,
+ KanbanTask,
+ TaskStatus,
+} from "@xtablo/shared-types";
+import { Button } from "@xtablo/ui/components/button";
+import { Plus } from "lucide-react";
+import { InlineTaskCreate } from "./InlineTaskCreate";
+import { KanbanTaskCard } from "./KanbanTaskCard";
+import type { TabloMember } from "./types";
+
+interface KanbanColumnProps {
+ column: KanbanColumnType;
+ members: TabloMember[];
+ onTaskClick: (task: KanbanTask) => void;
+ onAddTask: (status: KanbanColumnType["status"]) => void;
+ onAddTaskInline: (task: {
+ title: string;
+ description: string;
+ assignee_id?: string;
+ status: TaskStatus;
+ }) => void;
+ onDragStart: (e: React.DragEvent, task: KanbanTask) => void;
+ onDragOver: (e: React.DragEvent) => void;
+ onDrop: (e: React.DragEvent, targetStatus: KanbanColumnType["status"]) => void;
+}
+
+export const KanbanColumn = ({
+ column,
+ members,
+ onTaskClick,
+ onAddTask,
+ onAddTaskInline,
+ onDragStart,
+ onDragOver,
+ onDrop,
+}: KanbanColumnProps) => {
+ return (
+
+ {/* Column Header */}
+
+
+
{column.title}
+
+ {column.tasks.length}
+ {column.task_limit && `/${column.task_limit}`}
+
+
+
+
+
+ {/* Tasks */}
+
onDrop(e, column.status)}
+ >
+ {column.tasks.length === 0 ? (
+
+ Aucune tâche
+
+ ) : (
+ column.tasks.map((task: KanbanTask) => (
+
onDragStart(e, task)}
+ className="cursor-move"
+ >
+ onTaskClick(task)} />
+
+ ))
+ )}
+
+
+ {/* Inline Task Creation */}
+
+
+
+
+ );
+};
diff --git a/apps/main/src/components/kanban/KanbanTaskCard.tsx b/apps/main/src/components/kanban/KanbanTaskCard.tsx
new file mode 100644
index 0000000..3d19cfe
--- /dev/null
+++ b/apps/main/src/components/kanban/KanbanTaskCard.tsx
@@ -0,0 +1,80 @@
+import type { KanbanTask } from "@xtablo/shared-types";
+import { TypographyH4, TypographyMuted } from "@xtablo/ui/components/typography";
+import { User } from "lucide-react";
+
+interface KanbanTaskCardProps {
+ task: KanbanTask;
+ onClick: () => void;
+}
+
+export const KanbanTaskCard = ({ task, onClick }: KanbanTaskCardProps) => {
+ return (
+
+
+ {task.title}
+
+
+ {task.description && (
+
+ {task.description}
+
+ )}
+
+ {/* Status Pill */}
+ {task.status && (
+
+
+ {task.status === "todo"
+ ? "À faire"
+ : task.status === "in_progress"
+ ? "En cours"
+ : task.status === "in_review"
+ ? "En révision"
+ : task.status === "done"
+ ? "Terminé"
+ : task.status}
+
+
+ )}
+
+
+
+ {task.assignee_id ? (
+
+ {task.assignee_avatar ? (
+

+ ) : (
+
+ {task.assignee_name?.charAt(0).toUpperCase() || }
+
+ )}
+
+ ) : (
+
+
+
+ )}
+
+
+
+ );
+};
diff --git a/apps/main/src/components/kanban/TaskModal.tsx b/apps/main/src/components/kanban/TaskModal.tsx
new file mode 100644
index 0000000..88d692c
--- /dev/null
+++ b/apps/main/src/components/kanban/TaskModal.tsx
@@ -0,0 +1,172 @@
+import type { TaskStatus } from "@xtablo/shared-types";
+import { Button } from "@xtablo/ui/components/button";
+import { Input } from "@xtablo/ui/components/input";
+import { Label } from "@xtablo/ui/components/label";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@xtablo/ui/components/select";
+import { Textarea } from "@xtablo/ui/components/textarea";
+import { TypographyH2 } from "@xtablo/ui/components/typography";
+import { X } from "lucide-react";
+import { useEffect, useState } from "react";
+import { useCreateTask, useTask, useUpdateTask } from "../../hooks/tasks";
+import type { TabloMember } from "./types";
+
+interface TaskModalProps {
+ isOpen: boolean;
+ tabloId: string;
+ taskId: string | undefined;
+ onClose: () => void;
+ members: TabloMember[];
+ initialStatus?: TaskStatus;
+}
+
+export const TaskModal = ({
+ tabloId,
+ taskId,
+ isOpen,
+ onClose,
+ members,
+ initialStatus = "todo",
+}: TaskModalProps) => {
+ const { data: task = null } = useTask(taskId);
+ const [title, setTitle] = useState("");
+ const [description, setDescription] = useState("");
+ const [assigneeId, setAssigneeId] = useState("unassigned");
+
+ useEffect(() => {
+ if (task) {
+ setTitle(task.title ?? "");
+ setDescription(task.description ?? "");
+ setAssigneeId(task.assignee_id ?? "unassigned");
+ }
+ }, [task]);
+
+ const { mutate: createTask } = useCreateTask();
+ const { mutate: updateTask } = useUpdateTask();
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!title.trim()) return;
+
+ if (taskId && task) {
+ updateTask({
+ tablo_id: task.tablo_id,
+ id: task.id,
+ title: title.trim(),
+ description: description.trim(),
+ assignee_id: assigneeId !== "unassigned" ? assigneeId : undefined,
+ status: initialStatus,
+ });
+ } else {
+ createTask({
+ tablo_id: tabloId,
+ title: title.trim(),
+ description: description.trim(),
+ assignee_id: assigneeId !== "unassigned" ? assigneeId : undefined,
+ status: initialStatus,
+ });
+ }
+ // Reset form
+ setTitle("");
+ setDescription("");
+ setAssigneeId("unassigned");
+ onClose();
+ };
+
+ if (!isOpen) return null;
+
+ return (
+
+
+ {/* Header */}
+
+
+ {taskId ? "Modifier la tâche" : "Créer une tâche"}
+
+
+
+
+ {/* Form */}
+
+
+
+ );
+};
diff --git a/apps/main/src/components/kanban/index.ts b/apps/main/src/components/kanban/index.ts
new file mode 100644
index 0000000..dc644ff
--- /dev/null
+++ b/apps/main/src/components/kanban/index.ts
@@ -0,0 +1,6 @@
+export { InlineTaskCreate } from "./InlineTaskCreate";
+export { KanbanBoard } from "./KanbanBoard";
+export { KanbanColumn } from "./KanbanColumn";
+export { KanbanTaskCard } from "./KanbanTaskCard";
+export { TaskModal } from "./TaskModal";
+export type { TabloMember } from "./types";
diff --git a/apps/main/src/components/kanban/types.ts b/apps/main/src/components/kanban/types.ts
new file mode 100644
index 0000000..ca521ff
--- /dev/null
+++ b/apps/main/src/components/kanban/types.ts
@@ -0,0 +1,6 @@
+export interface TabloMember {
+ id: string;
+ name: string;
+ email: string;
+ is_admin: boolean;
+}
diff --git a/apps/main/src/hooks/tasks.ts b/apps/main/src/hooks/tasks.ts
new file mode 100644
index 0000000..490ea0b
--- /dev/null
+++ b/apps/main/src/hooks/tasks.ts
@@ -0,0 +1,241 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { toast } from "@xtablo/shared";
+import type {
+ KanbanTask,
+ KanbanTaskInsert,
+ KanbanTaskUpdate,
+ TaskStatus,
+} from "@xtablo/shared-types";
+import { supabase } from "../lib/supabase";
+
+// Fetch all tasks for a specific tablo
+export const useTasksByTablo = (tabloId: string | undefined) => {
+ return useQuery({
+ queryKey: ["tasks", "tablo", tabloId],
+ queryFn: async () => {
+ const { data, error } = await supabase
+ .from("tasks_with_assignee")
+ .select("*")
+ .eq("tablo_id", tabloId)
+ .order("position", { ascending: true });
+
+ if (error) throw error;
+ return data as KanbanTask[];
+ },
+ enabled: !!tabloId,
+ });
+};
+
+// Fetch single task
+export const useTask = (taskId: string | undefined) => {
+ return useQuery({
+ queryKey: ["tasks", "single", taskId],
+ queryFn: async () => {
+ const { data, error } = await supabase
+ .from("tasks_with_assignee")
+ .select("*")
+ .eq("id", taskId)
+ .single();
+
+ if (error) throw error;
+ return data as KanbanTask;
+ },
+ enabled: !!taskId,
+ });
+};
+
+// // Fetch tasks assigned to current user
+// export const useMyTasks = (tabloId?: string) => {
+// const user = useUser();
+
+// return useQuery({
+// queryKey: tabloId ? ["tasks", "my", tabloId] : ["tasks", "my"],
+// queryFn: async () => {
+// let query = supabase
+// .from("tasks_with_assignee")
+// .select("*")
+// .eq("assignee_id", user.id)
+// .order("position", { ascending: true });
+
+// if (tabloId) {
+// query = query.eq("tablo_id", tabloId);
+// }
+
+// const { data, error } = await query;
+
+// if (error) throw error;
+// return data as KanbanTask[];
+// },
+// enabled: !!user.id,
+// });
+// };
+
+// Create new task
+export const useCreateTask = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (task: KanbanTaskInsert) => {
+ const { data, error } = await supabase
+ .from("tasks")
+ .insert({
+ tablo_id: task.tablo_id,
+ title: task.title,
+ description: task.description,
+ status: task.status || "todo",
+ assignee_id: task.assignee_id,
+ position: task.position || 0,
+ })
+ .select()
+ .single();
+
+ if (error) throw error;
+ return data;
+ },
+ onSuccess: () => {
+ // Invalidate tasks list to refetch
+ queryClient.invalidateQueries({ queryKey: ["tasks"] });
+
+ toast.add(
+ {
+ title: "Tâche créée",
+ description: "La tâche a été créée avec succès",
+ type: "success",
+ },
+ { timeout: 3000 }
+ );
+ },
+ onError: (error) => {
+ toast.add(
+ {
+ title: "Erreur",
+ description: "Impossible de créer la tâche",
+ type: "error",
+ },
+ { timeout: 5000 }
+ );
+ console.error("Error creating task:", error);
+ },
+ });
+};
+
+// Update task
+export const useUpdateTask = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({ id, ...updates }: KanbanTaskUpdate) => {
+ const { data, error } = await supabase
+ .from("tasks")
+ .update(updates)
+ .eq("id", id)
+ .select()
+ .single();
+
+ if (error) throw error;
+ return data;
+ },
+ onSuccess: () => {
+ // Invalidate relevant queries
+ queryClient.invalidateQueries({ queryKey: ["tasks"] });
+
+ toast.add(
+ {
+ title: "Tâche mise à jour",
+ description: "La tâche a été mise à jour avec succès",
+ type: "success",
+ },
+ { timeout: 3000 }
+ );
+ },
+ onError: (error) => {
+ toast.add(
+ {
+ title: "Erreur",
+ description: "Impossible de mettre à jour la tâche",
+ type: "error",
+ },
+ { timeout: 5000 }
+ );
+ console.error("Error updating task:", error);
+ },
+ });
+};
+
+// Delete task (soft delete could be added if needed)
+export const useDeleteTask = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (taskId: string) => {
+ const { error } = await supabase.from("tasks").delete().eq("id", taskId);
+
+ if (error) throw error;
+ return taskId;
+ },
+ onSuccess: () => {
+ // Invalidate all task queries
+ queryClient.invalidateQueries({ queryKey: ["tasks"] });
+
+ toast.add(
+ {
+ title: "Tâche supprimée",
+ description: "La tâche a été supprimée avec succès",
+ type: "success",
+ },
+ { timeout: 3000 }
+ );
+ },
+ onError: (error) => {
+ toast.add(
+ {
+ title: "Erreur",
+ description: "Impossible de supprimer la tâche",
+ type: "error",
+ },
+ { timeout: 5000 }
+ );
+ console.error("Error deleting task:", error);
+ },
+ });
+};
+
+// Batch update task positions (useful for drag and drop)
+export const useUpdateTaskPositions = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (updates: Array<{ id: string; position: number; status?: TaskStatus }>) => {
+ const promises = updates.map(({ id, position, status }) =>
+ supabase
+ .from("tasks")
+ .update({ position, ...(status && { status }) })
+ .eq("id", id)
+ );
+
+ const results = await Promise.all(promises);
+ const errors = results.filter((r) => r.error);
+
+ if (errors.length > 0) {
+ throw new Error("Failed to update some task positions");
+ }
+
+ return updates;
+ },
+ onSuccess: () => {
+ // Invalidate queries for affected tablos
+ queryClient.invalidateQueries({ queryKey: ["tasks"] });
+ },
+ onError: (error) => {
+ toast.add(
+ {
+ title: "Erreur",
+ description: "Impossible de réorganiser les tâches",
+ type: "error",
+ },
+ { timeout: 5000 }
+ );
+ console.error("Error updating task positions:", error);
+ },
+ });
+};
diff --git a/apps/main/src/pages/tablo-details.tsx b/apps/main/src/pages/tablo-details.tsx
index a2dafc1..3cfd599 100644
--- a/apps/main/src/pages/tablo-details.tsx
+++ b/apps/main/src/pages/tablo-details.tsx
@@ -1,18 +1,29 @@
import { toast } from "@xtablo/shared";
import { TabloUpdate, UserTablo } from "@xtablo/shared/types/tablos.types";
import { Button } from "@xtablo/ui/components/button";
-import { ArrowLeft, BookOpen, Calendar, FileText, MessageSquare, Settings } from "lucide-react";
+import {
+ ArrowLeft,
+ BookOpen,
+ Calendar,
+ FileText,
+ ListChecks,
+ MessageSquare,
+ Settings,
+ Users,
+} from "lucide-react";
import { useEffect, useState } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { LoadingSpinner } from "../components/LoadingSpinner";
import { TabloDiscussionSection } from "../components/TabloDiscussionSection";
import { TabloEventsSection } from "../components/TabloEventsSection";
import { TabloFilesSection } from "../components/TabloFilesSection";
+import { TabloMembersSection } from "../components/TabloMembersSection";
import { TabloNotesSection } from "../components/TabloNotesSection";
import { TabloSettingsSection } from "../components/TabloSettingsSection";
+import { TabloTasksSection } from "../components/TabloTasksSection";
import { useTablosList, useUpdateTablo } from "../hooks/tablos";
-type TabSection = "files" | "discussion" | "notes" | "events" | "settings";
+type TabSection = "files" | "discussion" | "notes" | "events" | "tasks" | "members" | "settings";
export const TabloDetailsPage = () => {
const { tabloId } = useParams<{ tabloId: string }>();
@@ -107,6 +118,16 @@ export const TabloDetailsPage = () => {
label: "Événements",
icon: ,
},
+ {
+ id: "tasks",
+ label: "Tâches",
+ icon: ,
+ },
+ {
+ id: "members",
+ label: "Membres",
+ icon: ,
+ },
{
id: "settings",
label: "Paramètres",
@@ -179,13 +200,15 @@ export const TabloDetailsPage = () => {
{/* Main Content Area */}
-
+
{activeSection === "files" && }
{activeSection === "discussion" && (
)}
{activeSection === "notes" && }
{activeSection === "events" && }
+ {activeSection === "tasks" && }
+ {activeSection === "members" && }
{activeSection === "settings" && (
)}
diff --git a/apps/main/stats.html b/apps/main/stats.html
index 79a45c0..03b638f 100644
--- a/apps/main/stats.html
+++ b/apps/main/stats.html
@@ -4929,7 +4929,7 @@ var drawChart = (function (exports) {