Add task management
This commit is contained in:
parent
7b9be6da84
commit
a49dec34ff
24 changed files with 1867 additions and 218 deletions
253
CLAUDE.md
Normal file
253
CLAUDE.md
Normal 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.
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
86
apps/main/src/components/TabloMembersSection.test.tsx
Normal file
86
apps/main/src/components/TabloMembersSection.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
164
apps/main/src/components/TabloMembersSection.tsx
Normal file
164
apps/main/src/components/TabloMembersSection.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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<UserTablo | null>(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<HTMLInputElement>(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
|
|||
}
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
170
apps/main/src/components/TabloTasksSection.tsx
Normal file
170
apps/main/src/components/TabloTasksSection.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
176
apps/main/src/components/kanban/InlineTaskCreate.tsx
Normal file
176
apps/main/src/components/kanban/InlineTaskCreate.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
69
apps/main/src/components/kanban/KanbanBoard.tsx
Normal file
69
apps/main/src/components/kanban/KanbanBoard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
89
apps/main/src/components/kanban/KanbanColumn.tsx
Normal file
89
apps/main/src/components/kanban/KanbanColumn.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
80
apps/main/src/components/kanban/KanbanTaskCard.tsx
Normal file
80
apps/main/src/components/kanban/KanbanTaskCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
172
apps/main/src/components/kanban/TaskModal.tsx
Normal file
172
apps/main/src/components/kanban/TaskModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
6
apps/main/src/components/kanban/index.ts
Normal file
6
apps/main/src/components/kanban/index.ts
Normal 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";
|
||||
6
apps/main/src/components/kanban/types.ts
Normal file
6
apps/main/src/components/kanban/types.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export interface TabloMember {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
is_admin: boolean;
|
||||
}
|
||||
241
apps/main/src/hooks/tasks.ts
Normal file
241
apps/main/src/hooks/tasks.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -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: <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",
|
||||
label: "Paramètres",
|
||||
|
|
@ -179,13 +200,15 @@ export const TabloDetailsPage = () => {
|
|||
|
||||
{/* Main Content Area */}
|
||||
<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 === "discussion" && (
|
||||
<TabloDiscussionSection tablo={tablo} isAdmin={isAdmin} />
|
||||
)}
|
||||
{activeSection === "notes" && <TabloNotesSection 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" && (
|
||||
<TabloSettingsSection tablo={tablo} isAdmin={isAdmin} onEdit={handleEdit} />
|
||||
)}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
2
justfile
2
justfile
|
|
@ -36,7 +36,7 @@ merge-to-main:
|
|||
# Types recipes
|
||||
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -560,6 +560,64 @@ export type Database = {
|
|||
};
|
||||
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: {
|
||||
Row: {
|
||||
config: Json;
|
||||
|
|
@ -598,6 +656,44 @@ export type Database = {
|
|||
};
|
||||
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: {
|
||||
Row: {
|
||||
access_level: string | null;
|
||||
|
|
@ -719,6 +815,7 @@ export type Database = {
|
|||
Enums: {
|
||||
devis_status: "draft" | "sent" | "accepted" | "rejected" | "expired";
|
||||
subscription_plan: "none" | "trial" | "standard";
|
||||
task_status: "todo" | "in_progress" | "in_review" | "done";
|
||||
};
|
||||
CompositeTypes: {
|
||||
time_range: {
|
||||
|
|
@ -849,6 +946,7 @@ export const Constants = {
|
|||
Enums: {
|
||||
devis_status: ["draft", "sent", "accepted", "rejected", "expired"],
|
||||
subscription_plan: ["none", "trial", "standard"],
|
||||
task_status: ["todo", "in_progress", "in_review", "done"],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -21,17 +21,13 @@ export type {
|
|||
KanbanBoard,
|
||||
KanbanColumn,
|
||||
KanbanColumnUpdate,
|
||||
KanbanFilters,
|
||||
KanbanSort,
|
||||
KanbanTask,
|
||||
KanbanTaskInsert,
|
||||
KanbanTaskUpdate,
|
||||
KanbanUser,
|
||||
Priority,
|
||||
TaskAttachment,
|
||||
TaskComment,
|
||||
TaskStatus,
|
||||
TaskType,
|
||||
} from "./kanban.types.js";
|
||||
// ============================================================================
|
||||
// Stripe Types
|
||||
|
|
|
|||
|
|
@ -1,31 +1,9 @@
|
|||
export type Priority = "lowest" | "low" | "medium" | "high" | "highest";
|
||||
export type TaskStatus = "backlog" | "todo" | "in_progress" | "in_review" | "done";
|
||||
export type TaskType = "story" | "bug" | "task" | "epic" | "subtask";
|
||||
import { Database, Tables, TablesInsert, TablesUpdate } from "./database.types";
|
||||
import { RemoveNullFromObject } from "./utils";
|
||||
|
||||
export interface KanbanTask {
|
||||
id: string;
|
||||
title: string;
|
||||
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 type TaskStatus = "todo" | "in_progress" | "in_review" | "done";
|
||||
|
||||
export type KanbanTask = RemoveNullFromObject<Tables<"tasks_with_assignee">, "id" | "tablo_id">;
|
||||
|
||||
export interface KanbanColumn {
|
||||
id: string;
|
||||
|
|
@ -69,34 +47,11 @@ export interface TaskAttachment {
|
|||
uploaded_at: string;
|
||||
}
|
||||
|
||||
// Insert types for creating new items
|
||||
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 type TaskStatusType = Database["public"]["Enums"]["task_status"];
|
||||
|
||||
export interface KanbanTaskUpdate {
|
||||
title?: string;
|
||||
description?: string;
|
||||
status?: TaskStatus;
|
||||
priority?: Priority;
|
||||
type?: TaskType;
|
||||
assignee_id?: string;
|
||||
story_points?: number;
|
||||
labels?: string[];
|
||||
due_date?: string;
|
||||
position?: number;
|
||||
parent_task_id?: string;
|
||||
}
|
||||
export type KanbanTaskInsert = TablesInsert<"tasks">;
|
||||
|
||||
export type KanbanTaskUpdate = TablesUpdate<"tasks">;
|
||||
|
||||
export interface KanbanColumnUpdate {
|
||||
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 {
|
||||
field: "priority" | "created_at" | "updated_at" | "due_date" | "story_points";
|
||||
direction: "asc" | "desc";
|
||||
}
|
||||
|
||||
// User type for assignee selection
|
||||
export interface KanbanUser {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
avatar_url?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,15 +5,11 @@ export type {
|
|||
KanbanBoard,
|
||||
KanbanColumn,
|
||||
KanbanColumnUpdate,
|
||||
KanbanFilters,
|
||||
KanbanSort,
|
||||
KanbanTask,
|
||||
KanbanTaskInsert,
|
||||
KanbanTaskUpdate,
|
||||
KanbanUser,
|
||||
Priority,
|
||||
TaskAttachment,
|
||||
TaskComment,
|
||||
TaskStatus,
|
||||
TaskType,
|
||||
} from "@xtablo/shared-types";
|
||||
|
|
|
|||
|
|
@ -287,6 +287,9 @@ importers:
|
|||
'@xtablo/shared':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/shared
|
||||
'@xtablo/shared-types':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/shared-types
|
||||
'@xtablo/ui':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/ui
|
||||
|
|
|
|||
118
supabase/migrations/20251115211024_create_tasks_table.sql
Normal file
118
supabase/migrations/20251115211024_create_tasks_table.sql
Normal 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';
|
||||
|
||||
|
|
@ -566,6 +566,64 @@ export type Database = {
|
|||
}
|
||||
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: {
|
||||
Row: {
|
||||
config: Json
|
||||
|
|
@ -604,6 +662,44 @@ export type Database = {
|
|||
}
|
||||
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: {
|
||||
Row: {
|
||||
access_level: string | null
|
||||
|
|
@ -725,6 +821,7 @@ export type Database = {
|
|||
Enums: {
|
||||
devis_status: "draft" | "sent" | "accepted" | "rejected" | "expired"
|
||||
subscription_plan: "none" | "trial" | "standard"
|
||||
task_status: "todo" | "in_progress" | "in_review" | "done"
|
||||
}
|
||||
CompositeTypes: {
|
||||
time_range: {
|
||||
|
|
@ -857,6 +954,7 @@ export const Constants = {
|
|||
Enums: {
|
||||
devis_status: ["draft", "sent", "accepted", "rejected", "expired"],
|
||||
subscription_plan: ["none", "trial", "standard"],
|
||||
task_status: ["todo", "in_progress", "in_review", "done"],
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
|
|
|||
Loading…
Reference in a new issue