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.
This commit is contained in:
Julien Froidefond
2025-10-10 08:05:32 +02:00
parent ab4a7b3b3e
commit 7811453e02
10 changed files with 521 additions and 32 deletions

View File

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

View File

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

View File

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

View File

@@ -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[];
}

View File

@@ -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({
<div
className={`flex flex-col h-full bg-[var(--card)]/40 backdrop-blur-md border border-[var(--border)]/60 rounded-lg overflow-hidden relative before:absolute before:inset-0 before:bg-gradient-to-br before:from-[color-mix(in_srgb,var(--primary)_8%,transparent)] before:via-[color-mix(in_srgb,var(--primary)_4%,transparent)] before:to-transparent before:opacity-80 before:pointer-events-none ${className}`}
>
{/* Tags Input en mode édition */}
{isEditing && onTagsChange && (
{/* Tags et Tâche Input en mode édition */}
{isEditing && (onTagsChange || onTaskChange) && (
<div className="px-6 py-3 border-b border-[var(--border)]/30 bg-[var(--card)]/20 backdrop-blur-sm">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-[var(--foreground)]">
Tags:
</span>
<TagInput
tags={tags}
onChange={onTagsChange}
placeholder="Ajouter des tags..."
maxTags={10}
compactSuggestions={true}
/>
<div className="flex items-center gap-6">
{/* Tags Section */}
{onTagsChange && (
<div className="flex items-center gap-2 flex-1">
<span className="text-sm font-medium text-[var(--foreground)]">
Tags:
</span>
<TagInput
tags={tags}
onChange={onTagsChange}
placeholder="Ajouter des tags..."
maxTags={10}
compactSuggestions={true}
/>
</div>
)}
{/* Task Section */}
{onTaskChange && (
<div className="flex items-center gap-2 flex-1">
<span className="text-sm font-medium text-[var(--foreground)]">
Tâche:
</span>
<TaskSelector
selectedTaskId={selectedTaskId}
onTaskSelect={onTaskChange}
placeholder="Associer à une tâche..."
className="flex-1"
/>
</div>
)}
</div>
</div>
)}
@@ -356,22 +383,44 @@ export function MarkdownEditor({
{!isEditing ? (
/* Mode Aperçu avec Tags */
<div className="w-full flex flex-col">
{/* Barre des tags */}
{tags && tags.length > 0 && (
{/* Barre des tags et tâche */}
{(tags && tags.length > 0) || selectedTask ? (
<div className="p-3 border-b border-[var(--border)]/60 bg-[var(--card)]/40 backdrop-blur-md relative before:absolute before:inset-0 before:bg-gradient-to-r before:from-[color-mix(in_srgb,var(--primary)_8%,transparent)] before:via-[color-mix(in_srgb,var(--primary)_4%,transparent)] before:to-transparent before:opacity-80 before:pointer-events-none">
<span className="text-sm font-medium text-[var(--foreground)] relative z-10 mb-2 block">
Tags:
</span>
<TagDisplay
tags={tags}
availableTags={availableTags}
maxTags={10}
size="sm"
showColors={true}
showDot={false}
/>
<div className="flex items-start gap-6 relative z-10">
{/* Tags Section */}
{tags && tags.length > 0 && (
<div className="flex-1">
<span className="text-sm font-medium text-[var(--foreground)] mb-2 block">
Tags:
</span>
<TagDisplay
tags={tags}
availableTags={availableTags}
maxTags={10}
size="sm"
showColors={true}
showDot={false}
/>
</div>
)}
{/* Task Section */}
{selectedTask && (
<div className="flex-1">
<span className="text-sm font-medium text-[var(--foreground)] mb-2 block">
Tâche associée:
</span>
<div className="flex items-center gap-2">
<CheckSquare2 className="w-4 h-4 text-[var(--primary)]" />
<span className="text-sm text-[var(--foreground)]">
{selectedTask.title}
</span>
</div>
</div>
)}
</div>
</div>
)}
) : null}
{/* Barre de l'aperçu */}
<div className="p-3 border-b border-[var(--border)]/60 bg-[var(--card)]/40 backdrop-blur-md relative before:absolute before:inset-0 before:bg-gradient-to-r before:from-[color-mix(in_srgb,var(--primary)_8%,transparent)] before:via-[color-mix(in_srgb,var(--primary)_4%,transparent)] before:to-transparent before:opacity-80 before:pointer-events-none">

View File

@@ -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) {

View File

@@ -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<Task[]>([]);
const [tasksLoading, setTasksLoading] = useState(false);
const [tasksLoaded, setTasksLoaded] = useState(false); // Nouvel état pour tracker le chargement
const [taskSearch, setTaskSearch] = useState('');
const [selectedTask, setSelectedTask] = useState<Task | undefined>(undefined);
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 });
const [positionCalculated, setPositionCalculated] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(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 (
<div ref={containerRef} className={`relative ${className}`}>
{/* Trigger Button */}
<div
onClick={() => 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"
>
<div className="flex items-center gap-2 min-w-0 flex-1">
{selectedTask ? (
<>
<CheckSquare2 className="w-4 h-4 text-[var(--primary)] flex-shrink-0" />
<span className="truncate text-[var(--foreground)]">
{selectedTask.title}
</span>
</>
) : (
<span className="text-[var(--muted-foreground)]">
{placeholder}
</span>
)}
</div>
{selectedTask && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleClearTask();
}}
className="ml-2 p-1 hover:bg-[var(--destructive)]/10 rounded"
>
<X className="w-3 h-3 text-[var(--muted-foreground)]" />
</button>
)}
</div>
{/* Dropdown Portal */}
{isOpen &&
positionCalculated &&
createPortal(
<div
ref={dropdownRef}
className={`fixed z-[9999] bg-[var(--card)] border border-[var(--border)] rounded-md shadow-lg ${maxHeight} overflow-hidden`}
style={{
top: dropdownPosition.top + 4,
left: dropdownPosition.left,
width: containerRef.current?.offsetWidth || 300,
}}
>
{/* Search Input */}
<div className="p-2 border-b border-[var(--border)]">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-[var(--muted-foreground)]" />
<input
type="text"
placeholder="Rechercher une tâche..."
value={taskSearch}
onChange={(e) => 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
/>
</div>
</div>
{/* Tasks List */}
<div className="max-h-48 overflow-y-auto">
{tasksLoading ? (
<div className="p-3 text-center text-[var(--muted-foreground)]">
Chargement...
</div>
) : filteredTasks.length === 0 ? (
<div className="p-3 text-center text-[var(--muted-foreground)]">
{taskSearch
? 'Aucune tâche trouvée'
: 'Aucune tâche disponible'}
</div>
) : (
filteredTasks.map((task) => (
<button
key={task.id}
type="button"
onClick={() => handleTaskSelect(task)}
className="w-full px-3 py-2 text-left hover:bg-[var(--card-hover)] transition-colors flex items-center gap-2"
>
<CheckSquare2 className="w-4 h-4 text-[var(--primary)] flex-shrink-0" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-[var(--foreground)] truncate">
{task.title}
</div>
{task.description && (
<div className="text-xs text-[var(--muted-foreground)] truncate">
{task.description}
</div>
)}
</div>
{task.tagDetails && task.tagDetails.length > 0 && (
<div className="flex gap-1 flex-shrink-0">
{task.tagDetails.slice(0, 2).map((tag) => (
<span
key={tag.id}
className="px-1.5 py-0.5 text-xs rounded"
style={{
backgroundColor: `${tag.color}20`,
color: tag.color,
}}
>
{tag.name}
</span>
))}
</div>
)}
</button>
))
)}
</div>
</div>,
document.body
)}
{/* Overlay Portal */}
{isOpen &&
createPortal(
<div
className="fixed inset-0 z-[9998]"
onClick={() => setIsOpen(false)}
/>,
document.body
)}
</div>
);
}

View File

@@ -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<string, never>;
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
}));
}
/**