From 7811453e020bc24e7d0434e9208e11455bac11d5 Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Fri, 10 Oct 2025 08:05:32 +0200 Subject: [PATCH] feat(Notes): associate notes with tasks and enhance note management - Added taskId field to Note model for associating notes with tasks. - Updated API routes to handle taskId in note creation and updates. - Enhanced NotesPageClient to manage task associations within notes. - Integrated task selection in MarkdownEditor for better user experience. - Updated NotesService to map task data correctly when retrieving notes. --- .../migration.sql | 6 + prisma/schema.prisma | 3 + src/app/api/notes/[id]/route.ts | 3 +- src/app/api/notes/route.ts | 3 +- src/app/notes/NotesPageClient.tsx | 17 ++ src/clients/notes.ts | 2 + src/components/notes/MarkdownEditor.tsx | 107 +++++-- src/components/ui/TagInput.tsx | 24 ++ src/components/ui/TaskSelector.tsx | 262 ++++++++++++++++++ src/services/notes.ts | 126 ++++++++- 10 files changed, 521 insertions(+), 32 deletions(-) create mode 100644 prisma/migrations/20250115130000_add_task_to_notes/migration.sql create mode 100644 src/components/ui/TaskSelector.tsx diff --git a/prisma/migrations/20250115130000_add_task_to_notes/migration.sql b/prisma/migrations/20250115130000_add_task_to_notes/migration.sql new file mode 100644 index 0000000..85dd269 --- /dev/null +++ b/prisma/migrations/20250115130000_add_task_to_notes/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable +ALTER TABLE "Note" ADD COLUMN "taskId" TEXT; + +-- AddForeignKey +ALTER TABLE "Note" ADD CONSTRAINT "Note_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "tasks"("id") ON DELETE SET NULL ON UPDATE CASCADE; + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b21c42e..448bb23 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -51,6 +51,7 @@ model Task { primaryTag Tag? @relation("PrimaryTag", fields: [primaryTagId], references: [id]) dailyCheckboxes DailyCheckbox[] taskTags TaskTag[] + notes Note[] // Notes associées à cette tâche @@unique([source, sourceId]) @@map("tasks") @@ -129,9 +130,11 @@ model Note { title String content String // Markdown content userId String + taskId String? // Tâche associée à la note createdAt DateTime @default(now()) updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id], onDelete: Cascade) + task Task? @relation(fields: [taskId], references: [id]) noteTags NoteTag[] } diff --git a/src/app/api/notes/[id]/route.ts b/src/app/api/notes/[id]/route.ts index 43aa4bf..99957aa 100644 --- a/src/app/api/notes/[id]/route.ts +++ b/src/app/api/notes/[id]/route.ts @@ -52,7 +52,7 @@ export async function PUT( } const body = await request.json(); - const { title, content, tags } = body; + const { title, content, taskId, tags } = body; const resolvedParams = await params; const note = await notesService.updateNote( @@ -61,6 +61,7 @@ export async function PUT( { title, content, + taskId, tags, } ); diff --git a/src/app/api/notes/route.ts b/src/app/api/notes/route.ts index 5dcfecc..13b9f4d 100644 --- a/src/app/api/notes/route.ts +++ b/src/app/api/notes/route.ts @@ -46,7 +46,7 @@ export async function POST(request: Request) { } const body = await request.json(); - const { title, content, tags } = body; + const { title, content, taskId, tags } = body; if (!title || !content) { return NextResponse.json( @@ -59,6 +59,7 @@ export async function POST(request: Request) { title, content, userId: session.user.id, + taskId, tags, }); diff --git a/src/app/notes/NotesPageClient.tsx b/src/app/notes/NotesPageClient.tsx index 68dacbc..e719161 100644 --- a/src/app/notes/NotesPageClient.tsx +++ b/src/app/notes/NotesPageClient.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react'; import { Note } from '@/services/notes'; +import { Task } from '@/lib/types'; import { notesClient } from '@/clients/notes'; import { NotesList } from '@/components/notes/NotesList'; import { MarkdownEditor } from '@/components/notes/MarkdownEditor'; @@ -118,6 +119,7 @@ function NotesPageContent({ initialNotes }: { initialNotes: Note[] }) { const updatedNote = await notesClient.updateNote(selectedNote.id, { content: selectedNote.content, tags: selectedNote.tags, + taskId: selectedNote.taskId, }); // Mettre à jour la liste des notes mais pas selectedNote pour éviter la perte de focus @@ -144,6 +146,18 @@ function NotesPageContent({ initialNotes }: { initialNotes: Note[] }) { [selectedNote] ); + const handleTaskChange = useCallback( + (task: Task | null) => { + if (!selectedNote) return; + + setSelectedNote((prev) => + prev ? { ...prev, taskId: task?.id, task } : null + ); + setHasUnsavedChanges(true); + }, + [selectedNote] + ); + // Auto-save quand les tags changent useEffect(() => { if (hasUnsavedChanges && selectedNote) { @@ -258,6 +272,9 @@ function NotesPageContent({ initialNotes }: { initialNotes: Note[] }) { tags={selectedNote.tags} onTagsChange={handleTagsChange} availableTags={availableTags} + selectedTaskId={selectedNote.taskId} + selectedTask={selectedNote.task} + onTaskChange={handleTaskChange} onCreateNote={handleCreateNote} onToggleSidebar={() => setSidebarCollapsed(!sidebarCollapsed) diff --git a/src/clients/notes.ts b/src/clients/notes.ts index f29ff74..5a9a87f 100644 --- a/src/clients/notes.ts +++ b/src/clients/notes.ts @@ -4,12 +4,14 @@ import { Note } from '@/services/notes'; export interface CreateNoteData { title: string; content: string; + taskId?: string; // Tâche associée à la note tags?: string[]; } export interface UpdateNoteData { title?: string; content?: string; + taskId?: string; // Tâche associée à la note tags?: string[]; } diff --git a/src/components/notes/MarkdownEditor.tsx b/src/components/notes/MarkdownEditor.tsx index 057b065..7451e27 100644 --- a/src/components/notes/MarkdownEditor.tsx +++ b/src/components/notes/MarkdownEditor.tsx @@ -5,10 +5,11 @@ import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import rehypeHighlight from 'rehype-highlight'; import rehypeSanitize from 'rehype-sanitize'; -import { Eye, EyeOff, Edit3, X } from 'lucide-react'; +import { Eye, EyeOff, Edit3, X, CheckSquare2 } from 'lucide-react'; import { TagInput } from '@/components/ui/TagInput'; import { TagDisplay } from '@/components/ui/TagDisplay'; -import { Tag } from '@/lib/types'; +import { TaskSelector } from '@/components/ui/TaskSelector'; +import { Tag, Task } from '@/lib/types'; interface MarkdownEditorProps { value: string; @@ -20,6 +21,9 @@ interface MarkdownEditorProps { tags?: string[]; onTagsChange?: (tags: string[]) => void; availableTags?: Tag[]; + selectedTaskId?: string; + selectedTask?: Task | null; // Objet Task complet pour l'affichage + onTaskChange?: (task: Task | null) => void; onCreateNote?: () => void; onToggleSidebar?: () => void; } @@ -34,6 +38,9 @@ export function MarkdownEditor({ tags = [], onTagsChange, availableTags = [], + selectedTaskId, + selectedTask, + onTaskChange, onCreateNote, onToggleSidebar, }: MarkdownEditorProps) { @@ -333,20 +340,40 @@ export function MarkdownEditor({
- {/* Tags Input en mode édition */} - {isEditing && onTagsChange && ( + {/* Tags et Tâche Input en mode édition */} + {isEditing && (onTagsChange || onTaskChange) && (
-
- - Tags: - - +
+ {/* Tags Section */} + {onTagsChange && ( +
+ + Tags: + + +
+ )} + + {/* Task Section */} + {onTaskChange && ( +
+ + Tâche: + + +
+ )}
)} @@ -356,22 +383,44 @@ export function MarkdownEditor({ {!isEditing ? ( /* Mode Aperçu avec Tags */
- {/* Barre des tags */} - {tags && tags.length > 0 && ( + {/* Barre des tags et tâche */} + {(tags && tags.length > 0) || selectedTask ? (
- - Tags: - - +
+ {/* Tags Section */} + {tags && tags.length > 0 && ( +
+ + Tags: + + +
+ )} + + {/* Task Section */} + {selectedTask && ( +
+ + Tâche associée: + +
+ + + {selectedTask.title} + +
+
+ )} +
- )} + ) : null} {/* Barre de l'aperçu */}
diff --git a/src/components/ui/TagInput.tsx b/src/components/ui/TagInput.tsx index e2297ca..bf85a6f 100644 --- a/src/components/ui/TagInput.tsx +++ b/src/components/ui/TagInput.tsx @@ -101,6 +101,30 @@ export function TagInput({ } }, [inputValue, searchTags, clearSuggestions, hasLoadedPopularTags]); + // Gérer le clic extérieur pour fermer les suggestions + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + showSuggestions && + containerRef.current && + !containerRef.current.contains(event.target as Node) && + suggestionsRef.current && + !suggestionsRef.current.contains(event.target as Node) + ) { + setShowSuggestions(false); + setSelectedIndex(-1); + } + }; + + if (showSuggestions) { + document.addEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [showSuggestions]); + const addTag = (tagName: string) => { const trimmedTag = tagName.trim(); if (trimmedTag && !tags.includes(trimmedTag) && tags.length < maxTags) { diff --git a/src/components/ui/TaskSelector.tsx b/src/components/ui/TaskSelector.tsx new file mode 100644 index 0000000..4d9e251 --- /dev/null +++ b/src/components/ui/TaskSelector.tsx @@ -0,0 +1,262 @@ +'use client'; + +import { useState, useEffect, useRef } from 'react'; +import { createPortal } from 'react-dom'; +import { Task } from '@/lib/types'; +import { tasksClient } from '@/clients/tasks-client'; +import { Search, X, CheckSquare2 } from 'lucide-react'; + +interface TaskSelectorProps { + selectedTaskId?: string; + onTaskSelect: (task: Task | null) => void; + placeholder?: string; + className?: string; + excludePinnedTasks?: boolean; // Exclure les tâches avec des tags "objectif principal" + maxHeight?: string; // Hauteur maximale du dropdown +} + +export function TaskSelector({ + selectedTaskId, + onTaskSelect, + placeholder = 'Sélectionner une tâche...', + className = '', + excludePinnedTasks = true, + maxHeight = 'max-h-60', +}: TaskSelectorProps) { + const [isOpen, setIsOpen] = useState(false); + const [allTasks, setAllTasks] = useState([]); + const [tasksLoading, setTasksLoading] = useState(false); + const [tasksLoaded, setTasksLoaded] = useState(false); // Nouvel état pour tracker le chargement + const [taskSearch, setTaskSearch] = useState(''); + const [selectedTask, setSelectedTask] = useState(undefined); + const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 }); + const [positionCalculated, setPositionCalculated] = useState(false); + + const containerRef = useRef(null); + const dropdownRef = useRef(null); + + // Charger la tâche sélectionnée dès le montage si elle existe + useEffect(() => { + if (selectedTaskId && !tasksLoaded) { + setTasksLoading(true); + tasksClient + .getTasks() + .then((response: { data: Task[] }) => { + setAllTasks(response.data); + setTasksLoaded(true); + // Trouver la tâche sélectionnée + const task = response.data.find((t: Task) => t.id === selectedTaskId); + setSelectedTask(task); + }) + .catch(console.error) + .finally(() => setTasksLoading(false)); + } + }, [selectedTaskId, tasksLoaded]); + + // Calculer la position du dropdown + useEffect(() => { + if (isOpen && containerRef.current) { + const rect = containerRef.current.getBoundingClientRect(); + setDropdownPosition({ + top: rect.bottom + window.scrollY, + left: rect.left + window.scrollX, + }); + setPositionCalculated(true); + } else { + setPositionCalculated(false); + } + }, [isOpen]); + + // Charger toutes les tâches quand le dropdown s'ouvre (si pas déjà chargées) + useEffect(() => { + if (isOpen && !tasksLoaded) { + setTasksLoading(true); + tasksClient + .getTasks() + .then((response: { data: Task[] }) => { + setAllTasks(response.data); + setTasksLoaded(true); + // Trouver la tâche sélectionnée si elle existe + if (selectedTaskId) { + const task = response.data.find( + (t: Task) => t.id === selectedTaskId + ); + setSelectedTask(task); + } + }) + .catch(console.error) + .finally(() => setTasksLoading(false)); + } + }, [isOpen, selectedTaskId, tasksLoaded]); + + // Mettre à jour la tâche sélectionnée quand selectedTaskId change + useEffect(() => { + if (selectedTaskId && tasksLoaded) { + const task = allTasks.find((t: Task) => t.id === selectedTaskId); + setSelectedTask(task); + } else if (!selectedTaskId) { + setSelectedTask(undefined); + } + }, [selectedTaskId, tasksLoaded, allTasks]); + + // Filtrer les tâches selon la recherche et les options + const filteredTasks = allTasks.filter((task) => { + // Exclure les tâches avec des tags marqués comme "objectif principal" si demandé + if ( + excludePinnedTasks && + task.tagDetails && + task.tagDetails.some((tag) => tag.isPinned) + ) { + return false; + } + + // Filtrer selon la recherche + return ( + task.title.toLowerCase().includes(taskSearch.toLowerCase()) || + (task.description && + task.description.toLowerCase().includes(taskSearch.toLowerCase())) + ); + }); + + const handleTaskSelect = (task: Task) => { + setSelectedTask(task); + onTaskSelect(task); + setIsOpen(false); + setTaskSearch(''); + }; + + const handleClearTask = () => { + setSelectedTask(undefined); + onTaskSelect(null); + }; + + return ( +
+ {/* Trigger Button */} +
setIsOpen(!isOpen)} + className="w-full flex items-center justify-between px-3 py-2 text-sm bg-[var(--card)] border border-[var(--border)] rounded-md hover:bg-[var(--card-hover)] transition-colors cursor-pointer" + > +
+ {selectedTask ? ( + <> + + + {selectedTask.title} + + + ) : ( + + {placeholder} + + )} +
+ {selectedTask && ( + + )} +
+ + {/* Dropdown Portal */} + {isOpen && + positionCalculated && + createPortal( +
+ {/* Search Input */} +
+
+ + setTaskSearch(e.target.value)} + className="w-full pl-9 pr-3 py-2 text-sm bg-[var(--input)] border border-[var(--border)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/20 focus:border-[var(--primary)]" + autoFocus + /> +
+
+ + {/* Tasks List */} +
+ {tasksLoading ? ( +
+ Chargement... +
+ ) : filteredTasks.length === 0 ? ( +
+ {taskSearch + ? 'Aucune tâche trouvée' + : 'Aucune tâche disponible'} +
+ ) : ( + filteredTasks.map((task) => ( + + )) + )} +
+
, + document.body + )} + + {/* Overlay Portal */} + {isOpen && + createPortal( +
setIsOpen(false)} + />, + document.body + )} +
+ ); +} diff --git a/src/services/notes.ts b/src/services/notes.ts index 365e100..633e7c4 100644 --- a/src/services/notes.ts +++ b/src/services/notes.ts @@ -1,10 +1,14 @@ import { prisma } from '@/services/core/database'; +import { Task } from '@/lib/types'; +import { Prisma } from '@prisma/client'; export interface Note { id: string; title: string; content: string; userId: string; + taskId?: string; // Tâche associée à la note + task?: Task | null; // Objet Task complet createdAt: Date; updatedAt: Date; tags?: string[]; @@ -14,12 +18,14 @@ export interface CreateNoteData { title: string; content: string; userId: string; + taskId?: string; // Tâche associée à la note tags?: string[]; } export interface UpdateNoteData { title?: string; content?: string; + taskId?: string; // Tâche associée à la note tags?: string[]; } @@ -27,6 +33,68 @@ export interface UpdateNoteData { * Service pour la gestion des notes markdown */ export class NotesService { + /** + * Mappe un objet Task Prisma vers l'interface Task + */ + private mapPrismaTaskToTask( + prismaTask: Prisma.TaskGetPayload<{ + include: { + taskTags: { + include: { + tag: true; + }; + }; + primaryTag: true; + }; + }> | null + ): Task | null { + if (!prismaTask) return null; + + // Extraire les tags depuis les relations TaskTag + let tags: string[] = []; + let tagDetails: Array<{ + id: string; + name: string; + color: string; + isPinned: boolean; + }> = []; + + if (prismaTask.taskTags && Array.isArray(prismaTask.taskTags)) { + tags = prismaTask.taskTags.map((tt) => tt.tag.name); + tagDetails = prismaTask.taskTags.map((tt) => ({ + id: tt.tag.id, + name: tt.tag.name, + color: tt.tag.color, + isPinned: tt.tag.isPinned, + })); + } + + return { + id: prismaTask.id, + title: prismaTask.title, + description: prismaTask.description || undefined, + status: prismaTask.status as Task['status'], + priority: prismaTask.priority as Task['priority'], + source: prismaTask.source as Task['source'], + sourceId: prismaTask.sourceId || undefined, + tags, + tagDetails, + primaryTagId: prismaTask.primaryTagId || undefined, + primaryTag: prismaTask.primaryTag || undefined, + dueDate: prismaTask.dueDate || undefined, + completedAt: prismaTask.completedAt || undefined, + createdAt: prismaTask.createdAt, + updatedAt: prismaTask.updatedAt, + jiraProject: prismaTask.jiraProject || undefined, + jiraKey: prismaTask.jiraKey || undefined, + jiraType: prismaTask.jiraType || undefined, + tfsProject: prismaTask.tfsProject || undefined, + tfsPullRequestId: prismaTask.tfsPullRequestId || undefined, + tfsRepository: prismaTask.tfsRepository || undefined, + tfsSourceBranch: prismaTask.tfsSourceBranch || undefined, + tfsTargetBranch: prismaTask.tfsTargetBranch || undefined, + }; + } /** * Récupère toutes les notes d'un utilisateur */ @@ -39,12 +107,24 @@ export class NotesService { tag: true, }, }, + task: { + include: { + taskTags: { + include: { + tag: true, + }, + }, + primaryTag: true, + }, + }, }, orderBy: { updatedAt: 'desc' }, }); return notes.map((note) => ({ ...note, + taskId: note.taskId || undefined, // Convertir null en undefined + task: this.mapPrismaTaskToTask(note.task), // Mapper correctement l'objet Task tags: note.noteTags.map((nt) => nt.tag.name), })); } @@ -64,6 +144,16 @@ export class NotesService { tag: true, }, }, + task: { + include: { + taskTags: { + include: { + tag: true, + }, + }, + primaryTag: true, + }, + }, }, }); @@ -71,6 +161,8 @@ export class NotesService { return { ...note, + taskId: note.taskId || undefined, // Convertir null en undefined + task: this.mapPrismaTaskToTask(note.task), // Mapper correctement l'objet Task tags: note.noteTags.map((nt) => nt.tag.name), }; } @@ -84,6 +176,7 @@ export class NotesService { title: data.title, content: data.content, userId: data.userId, + taskId: data.taskId, // Ajouter le taskId noteTags: data.tags ? { create: data.tags.map((tagName) => ({ @@ -103,11 +196,23 @@ export class NotesService { tag: true, }, }, + task: { + include: { + taskTags: { + include: { + tag: true, + }, + }, + primaryTag: true, + }, + }, }, }); return { ...note, + taskId: note.taskId || undefined, // Convertir null en undefined + task: this.mapPrismaTaskToTask(note.task), // Mapper correctement l'objet Task tags: note.noteTags.map((nt) => nt.tag.name), }; } @@ -137,6 +242,7 @@ export class NotesService { updatedAt: Date; title?: string; content?: string; + taskId?: string; noteTags?: { deleteMany: Record; create: Array<{ @@ -159,6 +265,9 @@ export class NotesService { if (data.content !== undefined) { updateData.content = data.content; } + if (data.taskId !== undefined) { + updateData.taskId = data.taskId; + } // Gérer les tags si fournis if (data.tags !== undefined) { @@ -184,11 +293,23 @@ export class NotesService { tag: true, }, }, + task: { + include: { + taskTags: { + include: { + tag: true, + }, + }, + primaryTag: true, + }, + }, }, }); return { ...note, + taskId: note.taskId || undefined, // Convertir null en undefined + task: this.mapPrismaTaskToTask(note.task), // Mapper correctement l'objet Task tags: note.noteTags.map((nt) => nt.tag.name), }; } @@ -226,7 +347,10 @@ export class NotesService { orderBy: { updatedAt: 'desc' }, }); - return notes; + return notes.map((note) => ({ + ...note, + taskId: note.taskId || undefined, // Convertir null en undefined + })); } /**