diff --git a/components/forms/TagForm.tsx b/components/forms/TagForm.tsx index df9b6ba..fff505b 100644 --- a/components/forms/TagForm.tsx +++ b/components/forms/TagForm.tsx @@ -33,7 +33,8 @@ const PRESET_COLORS = [ export function TagForm({ isOpen, onClose, onSubmit, tag, loading = false }: TagFormProps) { const [formData, setFormData] = useState({ name: '', - color: '#3B82F6' + color: '#3B82F6', + isPinned: false }); const [errors, setErrors] = useState([]); @@ -42,12 +43,14 @@ export function TagForm({ isOpen, onClose, onSubmit, tag, loading = false }: Tag if (tag) { setFormData({ name: tag.name, - color: tag.color + color: tag.color, + isPinned: tag.isPinned || false }); } else { setFormData({ name: '', - color: TagsClient.generateRandomColor() + color: TagsClient.generateRandomColor(), + isPinned: false }); } setErrors([]); @@ -166,6 +169,30 @@ export function TagForm({ isOpen, onClose, onSubmit, tag, loading = false }: Tag + {/* Objectif principal */} +
+ +
+ +
+

+ Les tâches avec ce tag apparaîtront dans la section "Objectifs Principaux" au-dessus du Kanban +

+
+ {/* Erreurs */} {errors.length > 0 && (
diff --git a/components/kanban/BoardContainer.tsx b/components/kanban/BoardContainer.tsx index f4c9463..ebd9938 100644 --- a/components/kanban/BoardContainer.tsx +++ b/components/kanban/BoardContainer.tsx @@ -3,6 +3,7 @@ import { useState } from 'react'; import { KanbanBoard } from './Board'; import { SwimlanesBoard } from './SwimlanesBoard'; +import { ObjectivesBoard } from './ObjectivesBoard'; import { KanbanFilters } from './KanbanFilters'; import { EditTaskForm } from '@/components/forms/EditTaskForm'; import { useTasksContext } from '@/contexts/TasksContext'; @@ -12,13 +13,15 @@ import { UpdateTaskData } from '@/clients/tasks-client'; export function KanbanBoardContainer() { const { filteredTasks, + pinnedTasks, loading, createTask, deleteTask, updateTask, updateTaskOptimistic, kanbanFilters, - setKanbanFilters + setKanbanFilters, + tags } = useTasksContext(); const [editingTask, setEditingTask] = useState(null); @@ -47,6 +50,9 @@ export function KanbanBoardContainer() { }); }; + // Obtenir le nom du tag épinglé pour l'affichage + const pinnedTagName = tags.find(tag => tag.isPinned)?.name; + return ( <> + {/* Section Objectifs Principaux */} + {pinnedTasks.length > 0 && ( + + )} + {kanbanFilters.swimlanesByTags ? ( { + onFiltersChange({ + ...filters, + pinnedTag: tagName + }); + }; + const handleClearFilters = () => { onFiltersChange({}); }; diff --git a/components/kanban/ObjectivesBoard.tsx b/components/kanban/ObjectivesBoard.tsx new file mode 100644 index 0000000..cfdf147 --- /dev/null +++ b/components/kanban/ObjectivesBoard.tsx @@ -0,0 +1,178 @@ +'use client'; + +import { useState } from 'react'; +import { Task } from '@/lib/types'; +import { TaskCard } from './TaskCard'; +import { Card, CardHeader, CardContent } from '@/components/ui/Card'; +import { Badge } from '@/components/ui/Badge'; + +interface ObjectivesBoardProps { + tasks: Task[]; + onDeleteTask?: (taskId: string) => Promise; + onEditTask?: (task: Task) => void; + onUpdateTitle?: (taskId: string, newTitle: string) => Promise; + compactView?: boolean; + pinnedTagName?: string; +} + +export function ObjectivesBoard({ + tasks, + onDeleteTask, + onEditTask, + onUpdateTitle, + compactView = false, + pinnedTagName = "Objectifs" +}: ObjectivesBoardProps) { + const [isCollapsed, setIsCollapsed] = useState(false); + + if (tasks.length === 0) { + return null; // Ne rien afficher s'il n'y a pas d'objectifs + } + + return ( +
+
+ + +
+ + +
+ + {String(tasks.length).padStart(2, '0')} + + + {/* Bouton collapse séparé pour mobile */} + +
+
+
+ + {!isCollapsed && ( + + {(() => { + // Séparer les tâches par statut + const activeTasks = tasks.filter(task => task.status === 'todo' || task.status === 'in_progress'); + const completedTasks = tasks.filter(task => task.status === 'done'); + + return ( +
+ {/* Colonne En cours / À faire */} +
+
+
+

+ En cours / À faire +

+
+ + {activeTasks.length} + +
+ + {activeTasks.length === 0 ? ( +
+
🎯
+ Aucun objectif actif +
+ ) : ( +
+ {activeTasks.map(task => ( +
+ +
+ ))} +
+ )} +
+ + {/* Colonne Terminé */} +
+
+
+

+ Terminé +

+
+ + {completedTasks.length} + +
+ + {completedTasks.length === 0 ? ( +
+
âś…
+ Aucun objectif terminé +
+ ) : ( +
+ {completedTasks.map(task => ( +
+ +
+ ))} +
+ )} +
+
+ ); + })()} +
+ )} +
+
+
+ ); +} diff --git a/lib/types.ts b/lib/types.ts index b002cda..b2c1efc 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -29,6 +29,7 @@ export interface Tag { id: string; name: string; color: string; + isPinned?: boolean; // Tag pour objectifs principaux } // Interface pour les logs de synchronisation diff --git a/prisma/migrations/20250914200338_add_pinned_tags/migration.sql b/prisma/migrations/20250914200338_add_pinned_tags/migration.sql new file mode 100644 index 0000000..a3f0127 --- /dev/null +++ b/prisma/migrations/20250914200338_add_pinned_tags/migration.sql @@ -0,0 +1,15 @@ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_tags" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "color" TEXT NOT NULL DEFAULT '#6b7280', + "isPinned" BOOLEAN NOT NULL DEFAULT false +); +INSERT INTO "new_tags" ("color", "id", "name") SELECT "color", "id", "name" FROM "tags"; +DROP TABLE "tags"; +ALTER TABLE "new_tags" RENAME TO "tags"; +CREATE UNIQUE INDEX "tags_name_key" ON "tags"("name"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7164ce7..492de22 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -36,10 +36,11 @@ model Task { } model Tag { - id String @id @default(cuid()) - name String @unique - color String @default("#6b7280") - taskTags TaskTag[] + id String @id @default(cuid()) + name String @unique + color String @default("#6b7280") + isPinned Boolean @default(false) // Tag pour objectifs principaux + taskTags TaskTag[] @@map("tags") } diff --git a/services/tags.ts b/services/tags.ts index 21d3b59..b08b006 100644 --- a/services/tags.ts +++ b/services/tags.ts @@ -24,6 +24,7 @@ export const tagsService = { id: tag.id, name: tag.name, color: tag.color, + isPinned: tag.isPinned, usage: tag._count.taskTags })); }, @@ -41,7 +42,8 @@ export const tagsService = { return { id: tag.id, name: tag.name, - color: tag.color + color: tag.color, + isPinned: tag.isPinned }; }, @@ -62,14 +64,15 @@ export const tagsService = { return { id: tag.id, name: tag.name, - color: tag.color + color: tag.color, + isPinned: tag.isPinned }; }, /** * Crée un nouveau tag */ - async createTag(data: { name: string; color: string }): Promise { + async createTag(data: { name: string; color: string; isPinned?: boolean }): Promise { // Vérifier si le tag existe déjà const existing = await this.getTagByName(data.name); if (existing) { @@ -79,21 +82,23 @@ export const tagsService = { const tag = await prisma.tag.create({ data: { name: data.name.trim(), - color: data.color + color: data.color, + isPinned: data.isPinned || false } }); return { id: tag.id, name: tag.name, - color: tag.color + color: tag.color, + isPinned: tag.isPinned }; }, /** * Met à jour un tag */ - async updateTag(id: string, data: { name?: string; color?: string }): Promise { + async updateTag(id: string, data: { name?: string; color?: string; isPinned?: boolean }): Promise { // Vérifier que le tag existe const existing = await this.getTagById(id); if (!existing) { @@ -115,6 +120,9 @@ export const tagsService = { if (data.color !== undefined) { updateData.color = data.color; } + if (data.isPinned !== undefined) { + updateData.isPinned = data.isPinned; + } if (Object.keys(updateData).length === 0) { return existing; @@ -128,7 +136,8 @@ export const tagsService = { return { id: tag.id, name: tag.name, - color: tag.color + color: tag.color, + isPinned: tag.isPinned }; }, @@ -197,7 +206,8 @@ export const tagsService = { return tags.map(tag => ({ id: tag.id, name: tag.name, - color: tag.color + color: tag.color, + isPinned: tag.isPinned })); }, diff --git a/src/app/api/tags/[id]/route.ts b/src/app/api/tags/[id]/route.ts index 64d9ee8..0391f2a 100644 --- a/src/app/api/tags/[id]/route.ts +++ b/src/app/api/tags/[id]/route.ts @@ -6,10 +6,11 @@ import { tagsService } from '@/services/tags'; */ export async function GET( request: NextRequest, - { params }: { params: { id: string } } + { params }: { params: Promise<{ id: string }> } ) { try { - const tag = await tagsService.getTagById(params.id); + const { id } = await params; + const tag = await tagsService.getTagById(id); if (!tag) { return NextResponse.json( @@ -40,11 +41,12 @@ export async function GET( */ export async function PATCH( request: NextRequest, - { params }: { params: { id: string } } + { params }: { params: Promise<{ id: string }> } ) { try { + const { id } = await params; const body = await request.json(); - const { name, color } = body; + const { name, color, isPinned } = body; // Validation if (name !== undefined && (typeof name !== 'string' || !name.trim())) { @@ -61,7 +63,7 @@ export async function PATCH( ); } - const tag = await tagsService.updateTag(params.id, { name, color }); + const tag = await tagsService.updateTag(id, { name, color, isPinned }); if (!tag) { return NextResponse.json( @@ -107,10 +109,11 @@ export async function PATCH( */ export async function DELETE( request: NextRequest, - { params }: { params: { id: string } } + { params }: { params: Promise<{ id: string }> } ) { try { - await tagsService.deleteTag(params.id); + const { id } = await params; + await tagsService.deleteTag(id); return NextResponse.json({ message: 'Tag supprimé avec succès' diff --git a/src/contexts/TasksContext.tsx b/src/contexts/TasksContext.tsx index 64cfcfa..40461f4 100644 --- a/src/contexts/TasksContext.tsx +++ b/src/contexts/TasksContext.tsx @@ -29,6 +29,7 @@ interface TasksContextType { kanbanFilters: KanbanFilters; setKanbanFilters: (filters: KanbanFilters) => void; filteredTasks: Task[]; + pinnedTasks: Task[]; // Tâches avec tags épinglés (objectifs) // Tags tags: Tag[]; tagsLoading: boolean; @@ -54,9 +55,28 @@ export function TasksProvider({ children, initialTasks, initialStats }: TasksPro // État des filtres Kanban const [kanbanFilters, setKanbanFilters] = useState({}); - // Filtrage des tâches + // Séparer les tâches épinglées (objectifs) des autres + const { pinnedTasks, regularTasks } = useMemo(() => { + const pinnedTagNames = tags.filter(tag => tag.isPinned).map(tag => tag.name); + + const pinned: Task[] = []; + const regular: Task[] = []; + + tasksState.tasks.forEach(task => { + const hasPinnedTag = task.tags?.some(tagName => pinnedTagNames.includes(tagName)); + if (hasPinnedTag) { + pinned.push(task); + } else { + regular.push(task); + } + }); + + return { pinnedTasks: pinned, regularTasks: regular }; + }, [tasksState.tasks, tags]); + + // Filtrage des tâches régulières (pas les épinglées) const filteredTasks = useMemo(() => { - let filtered = tasksState.tasks; + let filtered = regularTasks; // Filtre par recherche if (kanbanFilters.search) { @@ -85,7 +105,7 @@ export function TasksProvider({ children, initialTasks, initialStats }: TasksPro } return filtered; - }, [tasksState.tasks, kanbanFilters]); + }, [regularTasks, kanbanFilters]); const contextValue: TasksContextType = { ...tasksState, @@ -94,7 +114,8 @@ export function TasksProvider({ children, initialTasks, initialStats }: TasksPro tagsError, kanbanFilters, setKanbanFilters, - filteredTasks + filteredTasks, + pinnedTasks }; return (