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 ( +
+ {/* Title Input */} + setTitle(e.target.value)} + placeholder="Titre de la tâche..." + className="text-sm" + required + /> + + {/* Advanced Options */} + {showAdvanced && ( +
+ {/* Description */} +