Add kanban

This commit is contained in:
Arthur Belleville 2025-07-09 22:41:01 +02:00
parent fc4d9ca6de
commit a54059bf12
No known key found for this signature in database
4 changed files with 846 additions and 0 deletions

View file

@ -49,6 +49,10 @@ export const App = () => {
<Route path=":tablo_id" />
<Route path="create" element={<CreateEventModal />} />
</Route>
<Route path="kanban" element={<NotFoundPage />}>
<Route index />
<Route path=":tablo_id" />
</Route>
<Route path="chantiers" element={<ChantiersPage />} />
<Route
path="chat"

View file

@ -0,0 +1,359 @@
import React, { useState, useCallback } from "react";
import { Plus, Filter } from "lucide-react";
import { Button } from "@ui/ui-library/button";
import { Icon } from "@ui/ui-library/icon";
import { SearchField } from "@ui/ui-library/search-field";
import { SearchInput } from "@ui/ui-library/search-field";
import {
KanbanBoard as KanbanBoardType,
KanbanTask,
KanbanFilters as KanbanFiltersType,
} from "@ui/types/kanban.types";
interface KanbanBoardProps {
board: KanbanBoardType;
onTaskCreate: (
task: Omit<KanbanTask, "id" | "created_at" | "updated_at">
) => void;
onTaskUpdate: (taskId: string, updates: Partial<KanbanTask>) => void;
onTaskDelete: (taskId: string) => void;
isLoading?: boolean;
}
export const KanbanBoard: React.FC<KanbanBoardProps> = ({
board,
onTaskCreate,
onTaskUpdate,
onTaskDelete,
isLoading = false,
}) => {
const [selectedTask, setSelectedTask] = useState<KanbanTask | null>(null);
const [showCreateModal, setShowCreateModal] = useState(false);
const [showFilters, setShowFilters] = useState(false);
const [filters] = useState<KanbanFiltersType>({});
const [searchQuery, setSearchQuery] = useState("");
const handleCreateTask = useCallback(() => {
setShowCreateModal(true);
console.log({ onTaskCreate, onTaskUpdate, onTaskDelete, isLoading }); // TODO: Implement actual functionality
}, [onTaskCreate, onTaskUpdate, onTaskDelete, isLoading]);
const handleTaskClick = useCallback((task: KanbanTask) => {
setSelectedTask(task);
}, []);
// Filter tasks based on search and filters
const filteredBoard = React.useMemo(() => {
if (!searchQuery && Object.keys(filters).length === 0) {
return board;
}
const filteredColumns = board.columns.map((column) => {
const filteredTasks = column.tasks.filter((task) => {
// Search filter
if (searchQuery) {
const searchLower = searchQuery.toLowerCase();
if (
!task.title.toLowerCase().includes(searchLower) &&
!task.description?.toLowerCase().includes(searchLower) &&
!task.assignee_name?.toLowerCase().includes(searchLower)
) {
return false;
}
}
// Assignee filter
if (filters.assignee && task.assignee_id !== filters.assignee) {
return false;
}
// Priority filter
if (filters.priority && filters.priority.length > 0) {
if (!filters.priority.includes(task.priority)) {
return false;
}
}
// Type filter
if (filters.type && filters.type.length > 0) {
if (!filters.type.includes(task.type)) {
return false;
}
}
// Labels filter
if (filters.labels && filters.labels.length > 0) {
if (
!task.labels ||
!filters.labels.some((label) => task.labels?.includes(label))
) {
return false;
}
}
return true;
});
return {
...column,
tasks: filteredTasks,
};
});
return {
...board,
columns: filteredColumns,
};
}, [board, searchQuery, filters]);
const totalTasks = board.columns.reduce(
(sum, column) => sum + column.tasks.length,
0
);
const filteredTasks = filteredBoard.columns.reduce(
(sum, column) => sum + column.tasks.length,
0
);
const getPriorityColor = (priority: string) => {
switch (priority) {
case "highest":
return "text-red-600";
case "high":
return "text-orange-600";
case "medium":
return "text-yellow-600";
case "low":
return "text-green-600";
case "lowest":
return "text-gray-600";
default:
return "text-gray-600";
}
};
const getTypeIcon = (type: string) => {
switch (type) {
case "bug":
return "🐛";
case "story":
return "📖";
case "task":
return "✓";
case "epic":
return "⚡";
case "subtask":
return "📝";
default:
return "📋";
}
};
return (
<div className="flex flex-col h-full bg-gray-50 dark:bg-gray-900">
{/* Header */}
<div className="flex items-center justify-between p-4 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center space-x-4">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
{board.name}
</h1>
<div className="text-sm text-gray-500 dark:text-gray-400">
{filteredTasks} of {totalTasks} tasks
</div>
</div>
<div className="flex items-center space-x-2">
<SearchField
value={searchQuery}
onChange={setSearchQuery}
className="w-64"
>
<SearchInput placeholder="Search tasks..." />
</SearchField>
<Button
variant="outline"
onPress={() => setShowFilters(!showFilters)}
className="relative"
>
<Icon>
<Filter className="w-4 h-4" />
</Icon>
{Object.keys(filters).length > 0 && (
<span className="absolute -top-1 -right-1 w-2 h-2 bg-blue-500 rounded-full"></span>
)}
</Button>
<Button variant="outline" onPress={() => handleCreateTask()}>
<Icon>
<Plus className="w-4 h-4" />
</Icon>
Create Task
</Button>
</div>
</div>
{/* Filters Panel */}
{showFilters && (
<div className="p-4 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<div className="text-sm text-gray-600 dark:text-gray-400">
Filters will be implemented here
</div>
</div>
)}
{/* Board Content */}
<div className="flex-1 overflow-x-auto overflow-y-hidden">
<div className="flex h-full p-4 space-x-4 min-w-max">
{filteredBoard.columns.map((column) => (
<div
key={column.id}
className="flex flex-col w-80 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700"
>
{/* Column Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center space-x-2">
<h3 className="font-semibold text-gray-900 dark:text-white">
{column.title}
</h3>
<span className="text-sm text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded">
{column.tasks.length}
</span>
</div>
<Button
variant="plain"
size="sm"
onPress={() => handleCreateTask()}
>
<Icon>
<Plus className="w-4 h-4" />
</Icon>
</Button>
</div>
{/* Column Tasks */}
<div className="flex-1 p-4 space-y-3 overflow-y-auto">
{column.tasks.map((task) => (
<div
key={task.id}
className="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600 hover:shadow-md transition-shadow cursor-pointer"
onClick={() => handleTaskClick(task)}
>
<div className="flex items-start justify-between mb-2">
<div className="flex items-center space-x-2">
<span className="text-sm">
{getTypeIcon(task.type)}
</span>
<span
className={`text-xs font-medium ${getPriorityColor(
task.priority
)}`}
>
{task.priority.toUpperCase()}
</span>
</div>
{task.assignee_name && (
<div className="w-6 h-6 bg-gray-300 dark:bg-gray-600 rounded-full flex items-center justify-center text-xs font-medium">
{task.assignee_name.charAt(0).toUpperCase()}
</div>
)}
</div>
<h4 className="font-medium text-gray-900 dark:text-white mb-1 line-clamp-2">
{task.title}
</h4>
{task.description && (
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
{task.description}
</p>
)}
<div className="flex items-center justify-between mt-2">
<div className="flex items-center space-x-2">
{task.labels && task.labels.length > 0 && (
<div className="flex space-x-1">
{task.labels.slice(0, 2).map((label, index) => (
<span
key={index}
className="text-xs bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 px-2 py-1 rounded"
>
{label}
</span>
))}
{task.labels.length > 2 && (
<span className="text-xs text-gray-500 dark:text-gray-400">
+{task.labels.length - 2}
</span>
)}
</div>
)}
</div>
{task.story_points && (
<span className="text-xs text-gray-500 dark:text-gray-400 bg-gray-200 dark:bg-gray-600 px-2 py-1 rounded">
{task.story_points}
</span>
)}
</div>
</div>
))}
</div>
</div>
))}
</div>
</div>
{/* TODO: Add modals when components are created */}
{showCreateModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg max-w-md w-full mx-4">
<h2 className="text-xl font-bold mb-4">Create Task</h2>
<p className="text-gray-600 dark:text-gray-400">
Task creation modal will be implemented here
</p>
<div className="mt-4 flex justify-end">
<Button
variant="outline"
onPress={() => setShowCreateModal(false)}
>
Close
</Button>
</div>
</div>
</div>
)}
{selectedTask && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg max-w-2xl w-full mx-4">
<h2 className="text-xl font-bold mb-4">{selectedTask.title}</h2>
<p className="text-gray-600 dark:text-gray-400 mb-4">
{selectedTask.description || "No description"}
</p>
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<strong>Priority:</strong> {selectedTask.priority}
</div>
<div>
<strong>Type:</strong> {selectedTask.type}
</div>
<div>
<strong>Status:</strong> {selectedTask.status}
</div>
<div>
<strong>Assignee:</strong>{" "}
{selectedTask.assignee_name || "Unassigned"}
</div>
</div>
<div className="flex justify-end">
<Button variant="outline" onPress={() => setSelectedTask(null)}>
Close
</Button>
</div>
</div>
</div>
)}
</div>
);
};

330
ui/src/pages/kanban.tsx Normal file
View file

@ -0,0 +1,330 @@
import React, { useState, useEffect } from "react";
import { useParams } from "react-router-dom";
import { KanbanBoard } from "@ui/components/kanban/KanbanBoard";
import { LoadingSpinner } from "@ui/components/LoadingSpinner";
import { useTablosList } from "@ui/hooks/tablos";
import {
KanbanBoard as KanbanBoardType,
KanbanTask,
KanbanColumn,
TaskStatus,
} from "@ui/types/kanban.types";
import {
Select,
SelectButton,
SelectPopover,
SelectListBox,
SelectListItem,
} from "@ui/ui-library/select";
// Mock data for demonstration
const createMockKanbanBoard = (
tabloId: string,
tabloName: string
): KanbanBoardType => {
const columns: KanbanColumn[] = [
{
id: "backlog",
title: "Backlog",
status: "backlog" as TaskStatus,
position: 0,
tasks: [
{
id: "1",
title: "Set up project structure",
description: "Create the initial project structure and configuration",
status: "backlog" as TaskStatus,
priority: "medium",
type: "task",
assignee_id: "user-1",
assignee_name: "John Doe",
reporter_id: "user-admin",
tablo_id: tabloId,
tablo_name: tabloName,
story_points: 3,
labels: ["setup", "infrastructure"],
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
position: 0,
comments_count: 2,
attachments_count: 0,
},
{
id: "2",
title: "Design user interface mockups",
description:
"Create wireframes and mockups for the main user interface",
status: "backlog" as TaskStatus,
priority: "high",
type: "story",
assignee_id: "user-2",
assignee_name: "Jane Smith",
reporter_id: "user-admin",
tablo_id: tabloId,
tablo_name: tabloName,
story_points: 5,
labels: ["design", "ui", "mockups"],
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
position: 1,
comments_count: 0,
attachments_count: 1,
},
],
},
{
id: "todo",
title: "To Do",
status: "todo" as TaskStatus,
position: 1,
tasks: [
{
id: "3",
title: "Implement authentication system",
description: "Build login and registration functionality",
status: "todo" as TaskStatus,
priority: "highest",
type: "story",
assignee_id: "user-1",
assignee_name: "John Doe",
reporter_id: "user-admin",
tablo_id: tabloId,
tablo_name: tabloName,
story_points: 8,
labels: ["authentication", "security"],
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
position: 0,
comments_count: 1,
attachments_count: 0,
},
],
},
{
id: "in_progress",
title: "In Progress",
status: "in_progress" as TaskStatus,
position: 2,
tasks: [
{
id: "4",
title: "Fix critical bug in payment processing",
description:
"Users are unable to complete payments due to a validation error",
status: "in_progress" as TaskStatus,
priority: "highest",
type: "bug",
assignee_id: "user-2",
assignee_name: "Jane Smith",
reporter_id: "user-3",
tablo_id: tabloId,
tablo_name: tabloName,
story_points: 2,
labels: ["critical", "payment", "bugfix"],
due_date: new Date(Date.now() + 86400000).toISOString(), // Tomorrow
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
position: 0,
comments_count: 5,
attachments_count: 2,
},
],
},
{
id: "in_review",
title: "In Review",
status: "in_review" as TaskStatus,
position: 3,
tasks: [
{
id: "5",
title: "Add search functionality to dashboard",
description:
"Implement search and filter capabilities for the main dashboard",
status: "in_review" as TaskStatus,
priority: "medium",
type: "story",
assignee_id: "user-1",
assignee_name: "John Doe",
reporter_id: "user-admin",
tablo_id: tabloId,
tablo_name: tabloName,
story_points: 5,
labels: ["search", "dashboard", "enhancement"],
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
position: 0,
comments_count: 3,
attachments_count: 0,
},
],
},
{
id: "done",
title: "Done",
status: "done" as TaskStatus,
position: 4,
tasks: [
{
id: "6",
title: "Update project documentation",
description: "Update README and API documentation",
status: "done" as TaskStatus,
priority: "low",
type: "task",
assignee_id: "user-2",
assignee_name: "Jane Smith",
reporter_id: "user-admin",
tablo_id: tabloId,
tablo_name: tabloName,
story_points: 2,
labels: ["documentation"],
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
position: 0,
comments_count: 1,
attachments_count: 0,
},
],
},
];
return {
id: "kanban-" + tabloId,
name: `${tabloName} Kanban Board`,
description: "Project task management board",
tablo_id: tabloId,
columns,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
};
export const KanbanPage: React.FC = () => {
const { tablo_id } = useParams();
const [selectedTabloId, setSelectedTabloId] = useState<string>(
tablo_id || "all"
);
const [kanbanBoard, setKanbanBoard] = useState<KanbanBoardType | null>(null);
const [isLoading, setIsLoading] = useState(true);
// Fetch tablos
const { data: tablos, isLoading: tablosLoading } = useTablosList();
// Create mock kanban board based on selected tablo
useEffect(() => {
if (tablos && selectedTabloId !== "all") {
const selectedTablo = tablos.find((t) => t.id === selectedTabloId);
if (selectedTablo) {
const mockBoard = createMockKanbanBoard(
selectedTablo.id,
selectedTablo.name
);
setKanbanBoard(mockBoard);
}
} else if (selectedTabloId === "all" && tablos && tablos.length > 0) {
// Create a combined board for all tablos
const mockBoard = createMockKanbanBoard("all", "All Projects");
setKanbanBoard(mockBoard);
}
setIsLoading(false);
}, [selectedTabloId, tablos]);
const handleTaskCreate = (
task: Omit<KanbanTask, "id" | "created_at" | "updated_at">
) => {
// TODO: Implement task creation
console.log("Create task:", task);
};
const handleTaskUpdate = (taskId: string, updates: Partial<KanbanTask>) => {
// TODO: Implement task updates
console.log("Update task:", taskId, updates);
};
const handleTaskDelete = (taskId: string) => {
// TODO: Implement task deletion
console.log("Delete task:", taskId);
};
if (tablosLoading || isLoading) {
return (
<div className="flex items-center justify-center h-screen">
<LoadingSpinner />
</div>
);
}
if (!kanbanBoard) {
return (
<div className="flex flex-col items-center justify-center h-screen">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
No Kanban Board
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6">
Select a tablo to view its kanban board
</p>
{tablos && tablos.length > 0 && (
<Select
selectedKey={selectedTabloId}
onSelectionChange={(key) => setSelectedTabloId(key as string)}
className="w-64"
>
<SelectButton className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white text-left" />
<SelectPopover>
<SelectListBox>
<SelectListItem id="all">All Projects</SelectListItem>
{tablos.map((tablo) => (
<SelectListItem key={tablo.id} id={tablo.id}>
{tablo.name}
</SelectListItem>
))}
</SelectListBox>
</SelectPopover>
</Select>
)}
</div>
);
}
return (
<div className="h-screen flex flex-col">
{/* Tablo Selector */}
<div className="flex items-center justify-between p-4 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center space-x-4">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
Project:
</label>
<Select
selectedKey={selectedTabloId}
onSelectionChange={(key) => setSelectedTabloId(key as string)}
className="w-64"
>
<SelectButton className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white text-left" />
<SelectPopover>
<SelectListBox>
<SelectListItem id="all">All Projects</SelectListItem>
{tablos?.map((tablo) => (
<SelectListItem key={tablo.id} id={tablo.id}>
{tablo.name}
</SelectListItem>
))}
</SelectListBox>
</SelectPopover>
</Select>
</div>
</div>
{/* Kanban Board */}
<div className="flex-1">
<KanbanBoard
board={kanbanBoard}
onTaskCreate={handleTaskCreate}
onTaskUpdate={handleTaskUpdate}
onTaskDelete={handleTaskDelete}
isLoading={isLoading}
/>
</div>
</div>
);
};

View file

@ -0,0 +1,153 @@
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";
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 interface KanbanColumn {
id: string;
title: string;
status: TaskStatus;
position: number;
color?: string;
task_limit?: number;
tasks: KanbanTask[];
}
export interface KanbanBoard {
id: string;
name: string;
description?: string;
tablo_id: string;
columns: KanbanColumn[];
created_at: string;
updated_at: string;
}
export interface TaskComment {
id: string;
task_id: string;
user_id: string;
user_name: string;
user_avatar?: string;
content: string;
created_at: string;
updated_at: string;
}
export interface TaskAttachment {
id: string;
task_id: string;
filename: string;
file_url: string;
file_size: number;
file_type: string;
uploaded_by: string;
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 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 interface KanbanColumnUpdate {
title?: string;
position?: number;
color?: string;
task_limit?: number;
}
// UI-specific types for drag and drop
export interface DragItem {
id: string;
type: "task" | "column";
sourceColumnId: string;
sourceIndex: number;
}
export interface DropResult {
draggedId: string;
source: {
columnId: string;
index: number;
};
destination: {
columnId: string;
index: number;
};
}
// 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;
}