Add task management

This commit is contained in:
Arthur Belleville 2025-11-15 22:58:06 +01:00
parent 7b9be6da84
commit a49dec34ff
No known key found for this signature in database
24 changed files with 1867 additions and 218 deletions

253
CLAUDE.md Normal file
View file

@ -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.

View file

@ -96,6 +96,7 @@
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",
"@typescript/native-preview": "7.0.0-dev.20251010.1", "@typescript/native-preview": "7.0.0-dev.20251010.1",
"@xtablo/shared": "workspace:*", "@xtablo/shared": "workspace:*",
"@xtablo/shared-types": "workspace:*",
"@xtablo/ui": "workspace:*", "@xtablo/ui": "workspace:*",
"ag-grid-community": "^33.2.1", "ag-grid-community": "^33.2.1",
"ag-grid-react": "^33.2.1", "ag-grid-react": "^33.2.1",

View file

@ -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(
<TabloMembersSection tablo={mockTablo} isAdmin={true} />
);
expect(container).toBeInTheDocument();
});
it("renders without crashing for non-admin", () => {
const { container } = renderWithProviders(
<TabloMembersSection tablo={mockTablo} isAdmin={false} />
);
expect(container).toBeInTheDocument();
});
it("shows invite section for admin", () => {
const { getByPlaceholderText } = renderWithProviders(
<TabloMembersSection tablo={mockTablo} isAdmin={true} />
);
expect(getByPlaceholderText("Email de l'utilisateur à inviter")).toBeInTheDocument();
});
it("does not show invite section for non-admin", () => {
const { queryByPlaceholderText } = renderWithProviders(
<TabloMembersSection tablo={mockTablo} isAdmin={false} />
);
expect(queryByPlaceholderText("Email de l'utilisateur à inviter")).not.toBeInTheDocument();
});
it("shows members list for all users", () => {
const { getByText } = renderWithProviders(
<TabloMembersSection tablo={mockTablo} isAdmin={false} />
);
expect(getByText("Membres du tablo")).toBeInTheDocument();
});
});

View file

@ -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 (
<div className="space-y-6">
<div className="flex items-start justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3">
<Users className="w-8 h-8" />
Membres
</h1>
<p className="text-muted-foreground mt-1">Gérez les membres de votre tablo</p>
</div>
</div>
{/* Invite User Section - Only for Admins */}
{isAdmin && (
<>
<div className="bg-card rounded-lg border border-border p-6">
<h3 className="text-lg font-semibold text-foreground mb-4">Inviter un utilisateur</h3>
<div className="flex space-x-2">
<input
type="email"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
placeholder="Email de l'utilisateur à inviter"
className="flex-1 px-3 py-2 border border-input rounded-md shadow-sm placeholder-muted-foreground focus:outline-none focus:ring-primary focus:border-primary bg-background text-foreground"
/>
{isInvitingUser ? (
<div className="flex justify-center items-center">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div>
</div>
) : (
<Button
type="button"
onClick={handleSendInvite}
disabled={!isEmailValid(inviteEmail)}
>
Inviter
</Button>
)}
</div>
</div>
{/* Pending Invites Section - Only for Admins */}
{pendingInvites && pendingInvites.length > 0 && (
<div className="bg-card rounded-lg border border-border p-6">
<h3 className="text-lg font-semibold text-foreground mb-4">
Invitations en attente
<span className="ml-2 text-sm font-normal text-muted-foreground">
({pendingInvites.length})
</span>
</h3>
<div className="space-y-2">
{pendingInvites.map((invite) => (
<div
key={invite.id}
className="flex items-center space-x-3 p-3 bg-orange-50 dark:bg-orange-950/20 rounded-lg border border-dashed border-orange-200 dark:border-orange-900/50"
>
<div className="w-10 h-10 bg-orange-100 dark:bg-orange-900/30 rounded-full flex items-center justify-center text-orange-600 dark:text-orange-400 text-sm font-medium">
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
</div>
<div className="flex-1">
<span className="text-sm font-medium text-foreground">
{invite.invited_email}
</span>
<span className="text-xs text-muted-foreground ml-2">(En attente)</span>
</div>
</div>
))}
</div>
</div>
)}
</>
)}
{/* Members List - Visible for All Users */}
<div className="bg-card rounded-lg border border-border p-6">
<h3 className="text-lg font-semibold text-foreground mb-4">
Membres du tablo
{filteredMembers && (
<span className="ml-2 text-sm font-normal text-muted-foreground">
({filteredMembers.length})
</span>
)}
</h3>
<div className="space-y-2">
{filteredMembers && filteredMembers.length > 0 ? (
filteredMembers.map((member, index) => (
<div key={index} className="flex items-center space-x-3 p-3 bg-muted rounded-lg">
<div className="w-10 h-10 bg-primary rounded-full flex items-center justify-center text-primary-foreground text-sm font-medium">
{member.name.charAt(0).toUpperCase()}
</div>
<div className="flex-1">
<span className="text-sm font-medium text-foreground">{member.name}</span>
{member.is_admin ? (
<span className="text-xs text-muted-foreground ml-2">
{member.id === currentUser?.id ? "(Vous, Admin)" : "(Admin)"}
</span>
) : (
<span className="text-xs text-muted-foreground ml-2">
{member.id === currentUser?.id ? "(Vous, Invité)" : "(Invité)"}
</span>
)}
</div>
</div>
))
) : (
<p className="text-sm text-muted-foreground">Aucun membre trouvé</p>
)}
</div>
</div>
</div>
);
};

View file

@ -2,10 +2,6 @@ import { TabloUpdate, UserTablo } from "@xtablo/shared/types/tablos.types";
import { Button } from "@xtablo/ui/components/button"; import { Button } from "@xtablo/ui/components/button";
import { Input } from "@xtablo/ui/components/input"; import { Input } from "@xtablo/ui/components/input";
import { useEffect, useRef, useState } from "react"; 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 { ClickOutside } from "./ClickOutside";
import { ImageColorPicker } from "./ImageColorPicker"; import { ImageColorPicker } from "./ImageColorPicker";
import { StatusPicker } from "./StatusPicker"; import { StatusPicker } from "./StatusPicker";
@ -19,23 +15,13 @@ interface TabloSettingsSectionProps {
} }
export const TabloSettingsSection = ({ tablo, isAdmin, onEdit }: TabloSettingsSectionProps) => { export const TabloSettingsSection = ({ tablo, isAdmin, onEdit }: TabloSettingsSectionProps) => {
const currentUser = useUser();
const [editData, setEditData] = useState<UserTablo | null>(tablo); const [editData, setEditData] = useState<UserTablo | null>(tablo);
const [isEditingName, setIsEditingName] = useState(false); const [isEditingName, setIsEditingName] = useState(false);
const [creationMode, setCreationMode] = useState<"image" | "color">("color"); const [creationMode, setCreationMode] = useState<"image" | "color">("color");
const [selectedColor, setSelectedColor] = useState(tablo.color || "bg-blue-500"); 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<HTMLInputElement>(null); const nameInputRef = useRef<HTMLInputElement>(null);
const filteredMembers = members?.filter(
(member) => !pendingInvites?.some((invite) => invite.invited_email === member.email)
);
useEffect(() => { useEffect(() => {
setEditData(tablo); setEditData(tablo);
setSelectedColor(tablo.color || "bg-blue-500"); 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; const currentData = editData || tablo;
return ( return (
@ -191,116 +165,8 @@ export const TabloSettingsSection = ({ tablo, isAdmin, onEdit }: TabloSettingsSe
} }
/> />
</div> </div>
{/* Invite User Section */}
<div className="bg-card rounded-lg border border-border p-6">
<h3 className="text-lg font-semibold text-foreground mb-4">Inviter un utilisateur</h3>
<div className="flex space-x-2">
<input
type="email"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
placeholder="Email de l'utilisateur à inviter"
className="flex-1 px-3 py-2 border border-input rounded-md shadow-sm placeholder-muted-foreground focus:outline-none focus:ring-primary focus:border-primary bg-background text-foreground"
/>
{isInvitingUser ? (
<div className="flex justify-center items-center">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div>
</div>
) : (
<Button
type="button"
onClick={handleSendInvite}
disabled={!isEmailValid(inviteEmail)}
>
Inviter
</Button>
)}
</div>
</div>
{pendingInvites && pendingInvites.length > 0 && (
<div className="bg-card rounded-lg border border-border p-6">
<h3 className="text-lg font-semibold text-foreground mb-4">
Invitations en attente
<span className="ml-2 text-sm font-normal text-muted-foreground">
({pendingInvites.length})
</span>
</h3>
<div className="space-y-2">
{pendingInvites.map((invite) => (
<div
key={invite.id}
className="flex items-center space-x-3 p-3 bg-orange-50 dark:bg-orange-950/20 rounded-lg border border-dashed border-orange-200 dark:border-orange-900/50"
>
<div className="w-10 h-10 bg-orange-100 dark:bg-orange-900/30 rounded-full flex items-center justify-center text-orange-600 dark:text-orange-400 text-sm font-medium">
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
</div>
<div className="flex-1">
<span className="text-sm font-medium text-foreground">
{invite.invited_email}
</span>
<span className="text-xs text-muted-foreground ml-2">(En attente)</span>
</div>
</div>
))}
</div>
</div>
)}
</div> </div>
)} )}
{/* Members List */}
<div className="bg-card rounded-lg border border-border p-6">
<h3 className="text-lg font-semibold text-foreground mb-4">
Membres
{filteredMembers && (
<span className="ml-2 text-sm font-normal text-muted-foreground">
({filteredMembers.length})
</span>
)}
</h3>
<div className="space-y-2">
{filteredMembers && filteredMembers.length > 0 ? (
filteredMembers.map((member, index) => (
<div key={index} className="flex items-center space-x-3 p-3 bg-muted rounded-lg">
<div className="w-10 h-10 bg-primary rounded-full flex items-center justify-center text-primary-foreground text-sm font-medium">
{member.name.charAt(0).toUpperCase()}
</div>
<div className="flex-1">
<span className="text-sm font-medium text-foreground">{member.name}</span>
{member.is_admin ? (
<span className="text-xs text-muted-foreground ml-2">
{member.id === currentUser?.id ? "(Vous, Admin)" : "(Admin)"}
</span>
) : (
<span className="text-xs text-muted-foreground ml-2">
{member.id === currentUser?.id ? "(Vous, Invité)" : "(Invité)"}
</span>
)}
</div>
</div>
))
) : (
<p className="text-sm text-muted-foreground">Aucun membre trouvé</p>
)}
</div>
</div>
{/* Pending Invites */}
</div> </div>
); );
}; };

View file

@ -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<KanbanColumn[]>([]);
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedTask, setSelectedTask] = useState<KanbanTask | null>(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 (
<div className="space-y-6">
<div className="flex items-start justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3">
<ListChecks className="w-8 h-8" />
Tâches
</h1>
<p className="text-muted-foreground mt-1">Gérez vos tâches avec un tableau Kanban</p>
</div>
</div>
{/* Kanban Board */}
<div className="bg-card rounded-lg border border-border p-6">
<KanbanBoard
columns={columns}
members={members}
onTaskClick={handleTaskClick}
onAddTask={handleAddTask}
onAddTaskInline={handleCreateTask}
onTaskMove={handleTaskMove}
/>
</div>
{/* Task Create Modal */}
<TaskModal
tabloId={tablo.id}
taskId={selectedTask?.id}
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
members={members}
/>
</div>
);
};

View file

@ -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<string>("unassigned");
const inputRef = useRef<HTMLInputElement>(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 (
<button
onClick={() => setIsCreating(true)}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-muted/50 rounded-lg transition-colors"
>
<Plus className="w-4 h-4" />
Ajouter une tâche
</button>
);
}
return (
<form onSubmit={handleSubmit} className="bg-card border border-border rounded-lg p-3 space-y-2">
{/* Title Input */}
<Input
ref={inputRef}
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Titre de la tâche..."
className="text-sm"
required
/>
{/* Advanced Options */}
{showAdvanced && (
<div className="space-y-2 pt-2 border-t border-border">
{/* Description */}
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Description (optionnel)"
className="text-sm min-h-[60px]"
/>
{/* Type and Assignee */}
<div className="grid grid-cols-2 gap-2">
{/* <div className="space-y-1">
<Label htmlFor="type" className="text-xs text-muted-foreground">
Type
</Label>
<Select value={type} onValueChange={(value) => setType(value as TaskType)}>
<SelectTrigger id="type" size="sm" className="w-full text-sm h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="task">Task</SelectItem>
<SelectItem value="story">Story</SelectItem>
<SelectItem value="bug">Bug</SelectItem>
<SelectItem value="epic">Epic</SelectItem>
<SelectItem value="subtask">Subtask</SelectItem>
</SelectContent>
</Select>
</div> */}
<div className="space-y-1">
<Label htmlFor="assignee" className="text-xs text-muted-foreground">
Assigné à
</Label>
<Select value={assigneeId} onValueChange={setAssigneeId}>
<SelectTrigger id="assignee" size="sm" className="w-full text-sm h-8">
<SelectValue placeholder="Non assigné" />
</SelectTrigger>
<SelectContent>
<SelectItem value="unassigned">Non assigné</SelectItem>
{members.map((member) => (
<SelectItem key={member.id} value={member.id}>
{member.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
)}
{/* Actions */}
<div className="flex items-center justify-between gap-1 pt-1">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowAdvanced(!showAdvanced)}
className="text-xs h-7 px-2"
>
{showAdvanced ? "Moins d'options" : "Plus d'options"}
</Button>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleCancel}
className="h-7 w-7 p-0"
>
<X className="w-3 h-3" />
</Button>
<Button type="submit" size="sm" className="h-7 px-2 text-xs">
Ajouter
</Button>
</div>
</div>
</form>
);
};

View file

@ -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<KanbanTask | null>(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 (
<div className="grid grid-cols-4 gap-4">
{columns.map((column) => (
<KanbanColumn
key={column.id}
column={column}
members={members}
onTaskClick={onTaskClick}
onAddTask={onAddTask}
onAddTaskInline={onAddTaskInline}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDrop={handleDrop}
/>
))}
</div>
);
};

View file

@ -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 (
<div className="flex flex-col bg-muted/30 rounded-lg p-3 h-full">
{/* Column Header */}
<div className="flex items-center justify-between mb-3 pb-2 border-b border-border">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-foreground">{column.title}</h3>
<span className="text-xs bg-muted px-2 py-0.5 rounded-full text-muted-foreground font-medium">
{column.tasks.length}
{column.task_limit && `/${column.task_limit}`}
</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => onAddTask(column.status)}
className="h-6 w-6 p-0"
>
<Plus className="w-4 h-4" />
</Button>
</div>
{/* Tasks */}
<div
className="flex-1 space-y-2 overflow-y-auto min-h-[200px]"
onDragOver={onDragOver}
onDrop={(e) => onDrop(e, column.status)}
>
{column.tasks.length === 0 ? (
<div className="flex items-center justify-center h-32 text-muted-foreground text-sm">
Aucune tâche
</div>
) : (
column.tasks.map((task: KanbanTask) => (
<div
key={task.id}
draggable
onDragStart={(e) => onDragStart(e, task)}
className="cursor-move"
>
<KanbanTaskCard task={task} onClick={() => onTaskClick(task)} />
</div>
))
)}
</div>
{/* Inline Task Creation */}
<div className="mt-2">
<InlineTaskCreate status={column.status} members={members} onSubmit={onAddTaskInline} />
</div>
</div>
);
};

View file

@ -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 (
<div
onClick={onClick}
className="bg-card border border-border rounded-lg p-3 hover:shadow-md transition-shadow cursor-pointer group"
>
<TypographyH4 className="font-medium text-foregroud mb-1 line-clamp-2">
{task.title}
</TypographyH4>
{task.description && (
<TypographyMuted className=" line-clamp-2 mt-1 mb-2 text-ellipsis overflow-hidden">
{task.description}
</TypographyMuted>
)}
{/* Status Pill */}
{task.status && (
<div className="mb-2">
<span
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
task.status === "todo"
? "bg-gray-100 text-gray-800"
: task.status === "in_progress"
? "bg-blue-100 text-blue-800"
: task.status === "in_review"
? "bg-yellow-100 text-yellow-800"
: task.status === "done"
? "bg-green-100 text-green-800"
: "bg-gray-100 text-gray-800"
}`}
>
{task.status === "todo"
? "À faire"
: task.status === "in_progress"
? "En cours"
: task.status === "in_review"
? "En révision"
: task.status === "done"
? "Terminé"
: task.status}
</span>
</div>
)}
<div className="flex items-center justify-end mt-2">
<div className="flex items-center gap-2">
{task.assignee_id ? (
<div className="flex items-center gap-1">
{task.assignee_avatar ? (
<img
src={task.assignee_avatar}
alt={task.assignee_name || "Assignee"}
className="w-6 h-6 rounded-full border border-border"
/>
) : (
<div className="w-6 h-6 rounded-full bg-primary flex items-center justify-center text-primary-foreground text-xs font-medium border border-border">
{task.assignee_name?.charAt(0).toUpperCase() || <User className="w-3 h-3" />}
</div>
)}
</div>
) : (
<div className="w-6 h-6 rounded-full bg-muted flex items-center justify-center text-muted-foreground border border-border">
<User className="w-3 h-3" />
</div>
)}
</div>
</div>
</div>
);
};

View file

@ -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<string>("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 (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-card border border-border rounded-lg p-6 w-full max-w-md shadow-xl">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<TypographyH2 className="text-xl font-bold text-foreground">
{taskId ? "Modifier la tâche" : "Créer une tâche"}
</TypographyH2>
<button
onClick={onClose}
className="text-muted-foreground hover:text-foreground transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-4">
{/* Title */}
<div className="space-y-2">
<Label htmlFor="title">Titre *</Label>
<Input
id="title"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Titre de la tâche"
required
/>
</div>
{/* Description */}
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Description de la tâche"
className="min-h-[100px]"
/>
</div>
{/* Type */}
{/* <div className="space-y-2">
<Label htmlFor="type">Type</Label>
<Select value={type} onValueChange={(value) => setType(value as TaskType)}>
<SelectTrigger id="type" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="task">Task</SelectItem>
<SelectItem value="story">Story</SelectItem>
<SelectItem value="bug">Bug</SelectItem>
<SelectItem value="epic">Epic</SelectItem>
<SelectItem value="subtask">Subtask</SelectItem>
</SelectContent>
</Select>
</div> */}
{/* Assignee */}
<div className="space-y-2">
<Label htmlFor="assignee">Assigné à</Label>
<Select value={assigneeId} onValueChange={setAssigneeId}>
<SelectTrigger id="assignee" className="w-full">
<SelectValue placeholder="Non assigné" />
</SelectTrigger>
<SelectContent>
<SelectItem value="unassigned">Non assigné</SelectItem>
{members.map((member) => (
<SelectItem key={member.id} value={member.id}>
{member.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Actions */}
<div className="flex justify-end gap-2 pt-4">
<Button type="button" variant="ghost" onClick={onClose}>
Annuler
</Button>
<Button type="submit">{taskId ? "Modifier" : "Créer"}</Button>
</div>
</form>
</div>
</div>
);
};

View file

@ -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";

View file

@ -0,0 +1,6 @@
export interface TabloMember {
id: string;
name: string;
email: string;
is_admin: boolean;
}

View file

@ -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);
},
});
};

View file

@ -1,18 +1,29 @@
import { toast } from "@xtablo/shared"; import { toast } from "@xtablo/shared";
import { TabloUpdate, UserTablo } from "@xtablo/shared/types/tablos.types"; import { TabloUpdate, UserTablo } from "@xtablo/shared/types/tablos.types";
import { Button } from "@xtablo/ui/components/button"; 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 { useEffect, useState } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { LoadingSpinner } from "../components/LoadingSpinner"; import { LoadingSpinner } from "../components/LoadingSpinner";
import { TabloDiscussionSection } from "../components/TabloDiscussionSection"; import { TabloDiscussionSection } from "../components/TabloDiscussionSection";
import { TabloEventsSection } from "../components/TabloEventsSection"; import { TabloEventsSection } from "../components/TabloEventsSection";
import { TabloFilesSection } from "../components/TabloFilesSection"; import { TabloFilesSection } from "../components/TabloFilesSection";
import { TabloMembersSection } from "../components/TabloMembersSection";
import { TabloNotesSection } from "../components/TabloNotesSection"; import { TabloNotesSection } from "../components/TabloNotesSection";
import { TabloSettingsSection } from "../components/TabloSettingsSection"; import { TabloSettingsSection } from "../components/TabloSettingsSection";
import { TabloTasksSection } from "../components/TabloTasksSection";
import { useTablosList, useUpdateTablo } from "../hooks/tablos"; 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 = () => { export const TabloDetailsPage = () => {
const { tabloId } = useParams<{ tabloId: string }>(); const { tabloId } = useParams<{ tabloId: string }>();
@ -107,6 +118,16 @@ export const TabloDetailsPage = () => {
label: "Événements", label: "Événements",
icon: <Calendar className="w-5 h-5" />, icon: <Calendar className="w-5 h-5" />,
}, },
{
id: "tasks",
label: "Tâches",
icon: <ListChecks className="w-5 h-5" />,
},
{
id: "members",
label: "Membres",
icon: <Users className="w-5 h-5" />,
},
{ {
id: "settings", id: "settings",
label: "Paramètres", label: "Paramètres",
@ -179,13 +200,15 @@ export const TabloDetailsPage = () => {
{/* Main Content Area */} {/* Main Content Area */}
<main className="flex-1 overflow-auto"> <main className="flex-1 overflow-auto">
<div className="max-w-5xl mx-auto p-6 h-full"> <div className="max-w-7xl mx-auto p-6 h-full">
{activeSection === "files" && <TabloFilesSection tablo={tablo} isAdmin={isAdmin} />} {activeSection === "files" && <TabloFilesSection tablo={tablo} isAdmin={isAdmin} />}
{activeSection === "discussion" && ( {activeSection === "discussion" && (
<TabloDiscussionSection tablo={tablo} isAdmin={isAdmin} /> <TabloDiscussionSection tablo={tablo} isAdmin={isAdmin} />
)} )}
{activeSection === "notes" && <TabloNotesSection tablo={tablo} isAdmin={isAdmin} />} {activeSection === "notes" && <TabloNotesSection tablo={tablo} isAdmin={isAdmin} />}
{activeSection === "events" && <TabloEventsSection tablo={tablo} isAdmin={isAdmin} />} {activeSection === "events" && <TabloEventsSection tablo={tablo} isAdmin={isAdmin} />}
{activeSection === "tasks" && <TabloTasksSection tablo={tablo} isAdmin={isAdmin} />}
{activeSection === "members" && <TabloMembersSection tablo={tablo} isAdmin={isAdmin} />}
{activeSection === "settings" && ( {activeSection === "settings" && (
<TabloSettingsSection tablo={tablo} isAdmin={isAdmin} onEdit={handleEdit} /> <TabloSettingsSection tablo={tablo} isAdmin={isAdmin} onEdit={handleEdit} />
)} )}

File diff suppressed because one or more lines are too long

View file

@ -36,7 +36,7 @@ merge-to-main:
# Types recipes # Types recipes
update-types: update-types:
npx supabase gen types typescript --project-id "mhcafqvzbrrwvahpvvzd" --schema public > packages/shared/src/types/database.types.ts && cp packages/shared/src/types/database.types.ts api/src/database.types.ts && cp packages/shared/src/types/database.types.ts xtablo-expo/lib/database.types.ts supabase gen types typescript --project-id "mhcafqvzbrrwvahpvvzd" --schema public > packages/shared-types/src/database.types.ts && cp packages/shared-types/src/database.types.ts xtablo-expo/lib/database.types.ts
# Expo recipes # Expo recipes

View file

@ -560,6 +560,64 @@ export type Database = {
}; };
Relationships: []; Relationships: [];
}; };
tasks: {
Row: {
assignee_id: string | null;
created_at: string;
description: string | null;
id: string;
position: number;
status: Database["public"]["Enums"]["task_status"];
tablo_id: string;
title: string;
updated_at: string;
};
Insert: {
assignee_id?: string | null;
created_at?: string;
description?: string | null;
id?: string;
position?: number;
status?: Database["public"]["Enums"]["task_status"];
tablo_id: string;
title: string;
updated_at?: string;
};
Update: {
assignee_id?: string | null;
created_at?: string;
description?: string | null;
id?: string;
position?: number;
status?: Database["public"]["Enums"]["task_status"];
tablo_id?: string;
title?: string;
updated_at?: string;
};
Relationships: [
{
foreignKeyName: "tasks_tablo_id_fkey";
columns: ["tablo_id"];
isOneToOne: false;
referencedRelation: "events_and_tablos";
referencedColumns: ["tablo_id"];
},
{
foreignKeyName: "tasks_tablo_id_fkey";
columns: ["tablo_id"];
isOneToOne: false;
referencedRelation: "tablos";
referencedColumns: ["id"];
},
{
foreignKeyName: "tasks_tablo_id_fkey";
columns: ["tablo_id"];
isOneToOne: false;
referencedRelation: "user_tablos";
referencedColumns: ["id"];
},
];
};
user_introductions: { user_introductions: {
Row: { Row: {
config: Json; config: Json;
@ -598,6 +656,44 @@ export type Database = {
}; };
Relationships: []; Relationships: [];
}; };
tasks_with_assignee: {
Row: {
assignee_avatar: string | null;
assignee_id: string | null;
assignee_name: string | null;
created_at: string | null;
description: string | null;
id: string | null;
position: number | null;
status: Database["public"]["Enums"]["task_status"] | null;
tablo_id: string | null;
title: string | null;
updated_at: string | null;
};
Relationships: [
{
foreignKeyName: "tasks_tablo_id_fkey";
columns: ["tablo_id"];
isOneToOne: false;
referencedRelation: "events_and_tablos";
referencedColumns: ["tablo_id"];
},
{
foreignKeyName: "tasks_tablo_id_fkey";
columns: ["tablo_id"];
isOneToOne: false;
referencedRelation: "tablos";
referencedColumns: ["id"];
},
{
foreignKeyName: "tasks_tablo_id_fkey";
columns: ["tablo_id"];
isOneToOne: false;
referencedRelation: "user_tablos";
referencedColumns: ["id"];
},
];
};
user_tablos: { user_tablos: {
Row: { Row: {
access_level: string | null; access_level: string | null;
@ -719,6 +815,7 @@ export type Database = {
Enums: { Enums: {
devis_status: "draft" | "sent" | "accepted" | "rejected" | "expired"; devis_status: "draft" | "sent" | "accepted" | "rejected" | "expired";
subscription_plan: "none" | "trial" | "standard"; subscription_plan: "none" | "trial" | "standard";
task_status: "todo" | "in_progress" | "in_review" | "done";
}; };
CompositeTypes: { CompositeTypes: {
time_range: { time_range: {
@ -849,6 +946,7 @@ export const Constants = {
Enums: { Enums: {
devis_status: ["draft", "sent", "accepted", "rejected", "expired"], devis_status: ["draft", "sent", "accepted", "rejected", "expired"],
subscription_plan: ["none", "trial", "standard"], subscription_plan: ["none", "trial", "standard"],
task_status: ["todo", "in_progress", "in_review", "done"],
}, },
}, },
} as const; } as const;

View file

@ -21,17 +21,13 @@ export type {
KanbanBoard, KanbanBoard,
KanbanColumn, KanbanColumn,
KanbanColumnUpdate, KanbanColumnUpdate,
KanbanFilters,
KanbanSort, KanbanSort,
KanbanTask, KanbanTask,
KanbanTaskInsert, KanbanTaskInsert,
KanbanTaskUpdate, KanbanTaskUpdate,
KanbanUser,
Priority,
TaskAttachment, TaskAttachment,
TaskComment, TaskComment,
TaskStatus, TaskStatus,
TaskType,
} from "./kanban.types.js"; } from "./kanban.types.js";
// ============================================================================ // ============================================================================
// Stripe Types // Stripe Types

View file

@ -1,31 +1,9 @@
export type Priority = "lowest" | "low" | "medium" | "high" | "highest"; import { Database, Tables, TablesInsert, TablesUpdate } from "./database.types";
export type TaskStatus = "backlog" | "todo" | "in_progress" | "in_review" | "done"; import { RemoveNullFromObject } from "./utils";
export type TaskType = "story" | "bug" | "task" | "epic" | "subtask";
export interface KanbanTask { export type TaskStatus = "todo" | "in_progress" | "in_review" | "done";
id: string;
title: string; export type KanbanTask = RemoveNullFromObject<Tables<"tasks_with_assignee">, "id" | "tablo_id">;
description?: string;
status: TaskStatus;
priority: Priority;
type: TaskType;
assignee_id?: string;
assignee_name?: string;
assignee_avatar?: string;
reporter_id: string;
reporter_name?: string;
tablo_id: string;
tablo_name?: string;
story_points?: number;
labels?: string[];
due_date?: string;
created_at: string;
updated_at: string;
position: number;
parent_task_id?: string; // For subtasks
comments_count?: number;
attachments_count?: number;
}
export interface KanbanColumn { export interface KanbanColumn {
id: string; id: string;
@ -69,34 +47,11 @@ export interface TaskAttachment {
uploaded_at: string; uploaded_at: string;
} }
// Insert types for creating new items export type TaskStatusType = Database["public"]["Enums"]["task_status"];
export interface KanbanTaskInsert {
title: string;
description?: string;
priority: Priority;
type: TaskType;
assignee_id?: string;
tablo_id: string;
story_points?: number;
labels?: string[];
due_date?: string;
position: number;
parent_task_id?: string;
}
export interface KanbanTaskUpdate { export type KanbanTaskInsert = TablesInsert<"tasks">;
title?: string;
description?: string; export type KanbanTaskUpdate = TablesUpdate<"tasks">;
status?: TaskStatus;
priority?: Priority;
type?: TaskType;
assignee_id?: string;
story_points?: number;
labels?: string[];
due_date?: string;
position?: number;
parent_task_id?: string;
}
export interface KanbanColumnUpdate { export interface KanbanColumnUpdate {
title?: string; title?: string;
@ -125,24 +80,7 @@ export interface DropResult {
}; };
} }
// Filter and sort types
export interface KanbanFilters {
assignee?: string;
priority?: Priority[];
type?: TaskType[];
labels?: string[];
search?: string;
}
export interface KanbanSort { export interface KanbanSort {
field: "priority" | "created_at" | "updated_at" | "due_date" | "story_points"; field: "priority" | "created_at" | "updated_at" | "due_date" | "story_points";
direction: "asc" | "desc"; direction: "asc" | "desc";
} }
// User type for assignee selection
export interface KanbanUser {
id: string;
name: string;
email: string;
avatar_url?: string;
}

View file

@ -5,15 +5,11 @@ export type {
KanbanBoard, KanbanBoard,
KanbanColumn, KanbanColumn,
KanbanColumnUpdate, KanbanColumnUpdate,
KanbanFilters,
KanbanSort, KanbanSort,
KanbanTask, KanbanTask,
KanbanTaskInsert, KanbanTaskInsert,
KanbanTaskUpdate, KanbanTaskUpdate,
KanbanUser,
Priority,
TaskAttachment, TaskAttachment,
TaskComment, TaskComment,
TaskStatus, TaskStatus,
TaskType,
} from "@xtablo/shared-types"; } from "@xtablo/shared-types";

View file

@ -287,6 +287,9 @@ importers:
'@xtablo/shared': '@xtablo/shared':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/shared version: link:../../packages/shared
'@xtablo/shared-types':
specifier: workspace:*
version: link:../../packages/shared-types
'@xtablo/ui': '@xtablo/ui':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/ui version: link:../../packages/ui

View file

@ -0,0 +1,118 @@
-- Create task_status enum type
CREATE TYPE task_status AS ENUM ('todo', 'in_progress', 'in_review', 'done');
-- Create tasks table
CREATE TABLE IF NOT EXISTS "public"."tasks" (
"id" "text" DEFAULT "public"."generate_random_string"(24) NOT NULL,
"tablo_id" "text" NOT NULL,
"title" character varying(500) NOT NULL,
"description" "text",
"status" task_status DEFAULT 'todo' NOT NULL,
"assignee_id" "uuid",
"position" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
"updated_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
CONSTRAINT "tasks_pkey" PRIMARY KEY ("id"),
CONSTRAINT "tasks_tablo_id_fkey" FOREIGN KEY ("tablo_id") REFERENCES "public"."tablos"("id") ON DELETE CASCADE,
CONSTRAINT "tasks_assignee_id_fkey" FOREIGN KEY ("assignee_id") REFERENCES "auth"."users"("id") ON DELETE SET NULL
);
ALTER TABLE "public"."tasks" OWNER TO "postgres";
-- Add comments
COMMENT ON TABLE "public"."tasks" IS 'Kanban tasks for tablos';
COMMENT ON COLUMN "public"."tasks"."id" IS 'Primary key: random 24-character alphanumeric string';
COMMENT ON COLUMN "public"."tasks"."tablo_id" IS 'Foreign key to tablos table';
COMMENT ON COLUMN "public"."tasks"."title" IS 'Task title';
COMMENT ON COLUMN "public"."tasks"."description" IS 'Optional task description';
COMMENT ON COLUMN "public"."tasks"."status" IS 'Task status: todo, in_progress, in_review, or done';
COMMENT ON COLUMN "public"."tasks"."assignee_id" IS 'Optional user ID of task assignee';
COMMENT ON COLUMN "public"."tasks"."position" IS 'Position within the column for ordering';
COMMENT ON COLUMN "public"."tasks"."created_at" IS 'Timestamp when the task was created';
COMMENT ON COLUMN "public"."tasks"."updated_at" IS 'Timestamp when the task was last updated (auto-updated by trigger)';
-- Create indexes for better query performance
CREATE INDEX "tasks_tablo_id_idx" ON "public"."tasks" USING btree ("tablo_id");
CREATE INDEX "tasks_status_idx" ON "public"."tasks" USING btree ("status");
CREATE INDEX "tasks_assignee_id_idx" ON "public"."tasks" USING btree ("assignee_id");
CREATE INDEX "tasks_position_idx" ON "public"."tasks" USING btree ("tablo_id", "status", "position");
-- Create trigger to auto-update updated_at timestamp
CREATE TRIGGER "update_tasks_updated_at"
BEFORE UPDATE ON "public"."tasks"
FOR EACH ROW
EXECUTE FUNCTION "public"."update_updated_at_column"();
-- Enable Row Level Security
ALTER TABLE "public"."tasks" ENABLE ROW LEVEL SECURITY;
-- RLS Policies: Users can only access tasks from tablos they have access to
CREATE POLICY "Users can view tasks from their tablos"
ON "public"."tasks"
FOR SELECT
USING (
EXISTS (
SELECT 1 FROM "public"."tablo_access"
WHERE "tablo_access"."tablo_id" = "tasks"."tablo_id"
AND "tablo_access"."user_id" = auth.uid()
AND "tablo_access"."is_active" = true
)
);
CREATE POLICY "Users can create tasks in their tablos"
ON "public"."tasks"
FOR INSERT
WITH CHECK (
EXISTS (
SELECT 1 FROM "public"."tablo_access"
WHERE "tablo_access"."tablo_id" = "tasks"."tablo_id"
AND "tablo_access"."user_id" = auth.uid()
AND "tablo_access"."is_active" = true
)
);
CREATE POLICY "Users can update tasks in their tablos"
ON "public"."tasks"
FOR UPDATE
USING (
EXISTS (
SELECT 1 FROM "public"."tablo_access"
WHERE "tablo_access"."tablo_id" = "tasks"."tablo_id"
AND "tablo_access"."user_id" = auth.uid()
AND "tablo_access"."is_active" = true
)
);
CREATE POLICY "Users can delete tasks in their tablos"
ON "public"."tasks"
FOR DELETE
USING (
EXISTS (
SELECT 1 FROM "public"."tablo_access"
WHERE "tablo_access"."tablo_id" = "tasks"."tablo_id"
AND "tablo_access"."user_id" = auth.uid()
AND "tablo_access"."is_active" = true
)
);
-- Create a view that includes assignee information
CREATE OR REPLACE VIEW "public"."tasks_with_assignee" WITH ("security_invoker"='true') AS
SELECT
t.id,
t.tablo_id,
t.title,
t.description,
t.status,
t.assignee_id,
t.position,
t.created_at,
t.updated_at,
p.name AS assignee_name,
p.avatar_url AS assignee_avatar
FROM "public"."tasks" t
LEFT JOIN "public"."profiles" p ON t.assignee_id = p.id;
ALTER TABLE "public"."tasks_with_assignee" OWNER TO "postgres";
COMMENT ON VIEW "public"."tasks_with_assignee" IS 'View that returns tasks with assignee information from profiles';

View file

@ -566,6 +566,64 @@ export type Database = {
} }
Relationships: [] Relationships: []
} }
tasks: {
Row: {
assignee_id: string | null
created_at: string
description: string | null
id: string
position: number
status: Database["public"]["Enums"]["task_status"]
tablo_id: string
title: string
updated_at: string
}
Insert: {
assignee_id?: string | null
created_at?: string
description?: string | null
id?: string
position?: number
status?: Database["public"]["Enums"]["task_status"]
tablo_id: string
title: string
updated_at?: string
}
Update: {
assignee_id?: string | null
created_at?: string
description?: string | null
id?: string
position?: number
status?: Database["public"]["Enums"]["task_status"]
tablo_id?: string
title?: string
updated_at?: string
}
Relationships: [
{
foreignKeyName: "tasks_tablo_id_fkey"
columns: ["tablo_id"]
isOneToOne: false
referencedRelation: "events_and_tablos"
referencedColumns: ["tablo_id"]
},
{
foreignKeyName: "tasks_tablo_id_fkey"
columns: ["tablo_id"]
isOneToOne: false
referencedRelation: "tablos"
referencedColumns: ["id"]
},
{
foreignKeyName: "tasks_tablo_id_fkey"
columns: ["tablo_id"]
isOneToOne: false
referencedRelation: "user_tablos"
referencedColumns: ["id"]
},
]
}
user_introductions: { user_introductions: {
Row: { Row: {
config: Json config: Json
@ -604,6 +662,44 @@ export type Database = {
} }
Relationships: [] Relationships: []
} }
tasks_with_assignee: {
Row: {
assignee_avatar: string | null
assignee_id: string | null
assignee_name: string | null
created_at: string | null
description: string | null
id: string | null
position: number | null
status: Database["public"]["Enums"]["task_status"] | null
tablo_id: string | null
title: string | null
updated_at: string | null
}
Relationships: [
{
foreignKeyName: "tasks_tablo_id_fkey"
columns: ["tablo_id"]
isOneToOne: false
referencedRelation: "events_and_tablos"
referencedColumns: ["tablo_id"]
},
{
foreignKeyName: "tasks_tablo_id_fkey"
columns: ["tablo_id"]
isOneToOne: false
referencedRelation: "tablos"
referencedColumns: ["id"]
},
{
foreignKeyName: "tasks_tablo_id_fkey"
columns: ["tablo_id"]
isOneToOne: false
referencedRelation: "user_tablos"
referencedColumns: ["id"]
},
]
}
user_tablos: { user_tablos: {
Row: { Row: {
access_level: string | null access_level: string | null
@ -725,6 +821,7 @@ export type Database = {
Enums: { Enums: {
devis_status: "draft" | "sent" | "accepted" | "rejected" | "expired" devis_status: "draft" | "sent" | "accepted" | "rejected" | "expired"
subscription_plan: "none" | "trial" | "standard" subscription_plan: "none" | "trial" | "standard"
task_status: "todo" | "in_progress" | "in_review" | "done"
} }
CompositeTypes: { CompositeTypes: {
time_range: { time_range: {
@ -857,6 +954,7 @@ export const Constants = {
Enums: { Enums: {
devis_status: ["draft", "sent", "accepted", "rejected", "expired"], devis_status: ["draft", "sent", "accepted", "rejected", "expired"],
subscription_plan: ["none", "trial", "standard"], subscription_plan: ["none", "trial", "standard"],
task_status: ["todo", "in_progress", "in_review", "done"],
}, },
}, },
} as const } as const