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:
@@ -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;
|
||||||
|
|
||||||
@@ -51,6 +51,7 @@ model Task {
|
|||||||
primaryTag Tag? @relation("PrimaryTag", fields: [primaryTagId], references: [id])
|
primaryTag Tag? @relation("PrimaryTag", fields: [primaryTagId], references: [id])
|
||||||
dailyCheckboxes DailyCheckbox[]
|
dailyCheckboxes DailyCheckbox[]
|
||||||
taskTags TaskTag[]
|
taskTags TaskTag[]
|
||||||
|
notes Note[] // Notes associées à cette tâche
|
||||||
|
|
||||||
@@unique([source, sourceId])
|
@@unique([source, sourceId])
|
||||||
@@map("tasks")
|
@@map("tasks")
|
||||||
@@ -129,9 +130,11 @@ model Note {
|
|||||||
title String
|
title String
|
||||||
content String // Markdown content
|
content String // Markdown content
|
||||||
userId String
|
userId String
|
||||||
|
taskId String? // Tâche associée à la note
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
task Task? @relation(fields: [taskId], references: [id])
|
||||||
noteTags NoteTag[]
|
noteTags NoteTag[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export async function PUT(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { title, content, tags } = body;
|
const { title, content, taskId, tags } = body;
|
||||||
|
|
||||||
const resolvedParams = await params;
|
const resolvedParams = await params;
|
||||||
const note = await notesService.updateNote(
|
const note = await notesService.updateNote(
|
||||||
@@ -61,6 +61,7 @@ export async function PUT(
|
|||||||
{
|
{
|
||||||
title,
|
title,
|
||||||
content,
|
content,
|
||||||
|
taskId,
|
||||||
tags,
|
tags,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { title, content, tags } = body;
|
const { title, content, taskId, tags } = body;
|
||||||
|
|
||||||
if (!title || !content) {
|
if (!title || !content) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -59,6 +59,7 @@ export async function POST(request: Request) {
|
|||||||
title,
|
title,
|
||||||
content,
|
content,
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
|
taskId,
|
||||||
tags,
|
tags,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { Note } from '@/services/notes';
|
import { Note } from '@/services/notes';
|
||||||
|
import { Task } from '@/lib/types';
|
||||||
import { notesClient } from '@/clients/notes';
|
import { notesClient } from '@/clients/notes';
|
||||||
import { NotesList } from '@/components/notes/NotesList';
|
import { NotesList } from '@/components/notes/NotesList';
|
||||||
import { MarkdownEditor } from '@/components/notes/MarkdownEditor';
|
import { MarkdownEditor } from '@/components/notes/MarkdownEditor';
|
||||||
@@ -118,6 +119,7 @@ function NotesPageContent({ initialNotes }: { initialNotes: Note[] }) {
|
|||||||
const updatedNote = await notesClient.updateNote(selectedNote.id, {
|
const updatedNote = await notesClient.updateNote(selectedNote.id, {
|
||||||
content: selectedNote.content,
|
content: selectedNote.content,
|
||||||
tags: selectedNote.tags,
|
tags: selectedNote.tags,
|
||||||
|
taskId: selectedNote.taskId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mettre à jour la liste des notes mais pas selectedNote pour éviter la perte de focus
|
// 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]
|
[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
|
// Auto-save quand les tags changent
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasUnsavedChanges && selectedNote) {
|
if (hasUnsavedChanges && selectedNote) {
|
||||||
@@ -258,6 +272,9 @@ function NotesPageContent({ initialNotes }: { initialNotes: Note[] }) {
|
|||||||
tags={selectedNote.tags}
|
tags={selectedNote.tags}
|
||||||
onTagsChange={handleTagsChange}
|
onTagsChange={handleTagsChange}
|
||||||
availableTags={availableTags}
|
availableTags={availableTags}
|
||||||
|
selectedTaskId={selectedNote.taskId}
|
||||||
|
selectedTask={selectedNote.task}
|
||||||
|
onTaskChange={handleTaskChange}
|
||||||
onCreateNote={handleCreateNote}
|
onCreateNote={handleCreateNote}
|
||||||
onToggleSidebar={() =>
|
onToggleSidebar={() =>
|
||||||
setSidebarCollapsed(!sidebarCollapsed)
|
setSidebarCollapsed(!sidebarCollapsed)
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import { Note } from '@/services/notes';
|
|||||||
export interface CreateNoteData {
|
export interface CreateNoteData {
|
||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
|
taskId?: string; // Tâche associée à la note
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateNoteData {
|
export interface UpdateNoteData {
|
||||||
title?: string;
|
title?: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
|
taskId?: string; // Tâche associée à la note
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import ReactMarkdown from 'react-markdown';
|
|||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import rehypeHighlight from 'rehype-highlight';
|
import rehypeHighlight from 'rehype-highlight';
|
||||||
import rehypeSanitize from 'rehype-sanitize';
|
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 { TagInput } from '@/components/ui/TagInput';
|
||||||
import { TagDisplay } from '@/components/ui/TagDisplay';
|
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 {
|
interface MarkdownEditorProps {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -20,6 +21,9 @@ interface MarkdownEditorProps {
|
|||||||
tags?: string[];
|
tags?: string[];
|
||||||
onTagsChange?: (tags: string[]) => void;
|
onTagsChange?: (tags: string[]) => void;
|
||||||
availableTags?: Tag[];
|
availableTags?: Tag[];
|
||||||
|
selectedTaskId?: string;
|
||||||
|
selectedTask?: Task | null; // Objet Task complet pour l'affichage
|
||||||
|
onTaskChange?: (task: Task | null) => void;
|
||||||
onCreateNote?: () => void;
|
onCreateNote?: () => void;
|
||||||
onToggleSidebar?: () => void;
|
onToggleSidebar?: () => void;
|
||||||
}
|
}
|
||||||
@@ -34,6 +38,9 @@ export function MarkdownEditor({
|
|||||||
tags = [],
|
tags = [],
|
||||||
onTagsChange,
|
onTagsChange,
|
||||||
availableTags = [],
|
availableTags = [],
|
||||||
|
selectedTaskId,
|
||||||
|
selectedTask,
|
||||||
|
onTaskChange,
|
||||||
onCreateNote,
|
onCreateNote,
|
||||||
onToggleSidebar,
|
onToggleSidebar,
|
||||||
}: MarkdownEditorProps) {
|
}: MarkdownEditorProps) {
|
||||||
@@ -333,20 +340,40 @@ export function MarkdownEditor({
|
|||||||
<div
|
<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}`}
|
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 */}
|
{/* Tags et Tâche Input en mode édition */}
|
||||||
{isEditing && onTagsChange && (
|
{isEditing && (onTagsChange || onTaskChange) && (
|
||||||
<div className="px-6 py-3 border-b border-[var(--border)]/30 bg-[var(--card)]/20 backdrop-blur-sm">
|
<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">
|
<div className="flex items-center gap-6">
|
||||||
<span className="text-sm font-medium text-[var(--foreground)]">
|
{/* Tags Section */}
|
||||||
Tags:
|
{onTagsChange && (
|
||||||
</span>
|
<div className="flex items-center gap-2 flex-1">
|
||||||
<TagInput
|
<span className="text-sm font-medium text-[var(--foreground)]">
|
||||||
tags={tags}
|
Tags:
|
||||||
onChange={onTagsChange}
|
</span>
|
||||||
placeholder="Ajouter des tags..."
|
<TagInput
|
||||||
maxTags={10}
|
tags={tags}
|
||||||
compactSuggestions={true}
|
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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -356,22 +383,44 @@ export function MarkdownEditor({
|
|||||||
{!isEditing ? (
|
{!isEditing ? (
|
||||||
/* Mode Aperçu avec Tags */
|
/* Mode Aperçu avec Tags */
|
||||||
<div className="w-full flex flex-col">
|
<div className="w-full flex flex-col">
|
||||||
{/* Barre des tags */}
|
{/* Barre des tags et tâche */}
|
||||||
{tags && tags.length > 0 && (
|
{(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">
|
<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">
|
<div className="flex items-start gap-6 relative z-10">
|
||||||
Tags:
|
{/* Tags Section */}
|
||||||
</span>
|
{tags && tags.length > 0 && (
|
||||||
<TagDisplay
|
<div className="flex-1">
|
||||||
tags={tags}
|
<span className="text-sm font-medium text-[var(--foreground)] mb-2 block">
|
||||||
availableTags={availableTags}
|
Tags:
|
||||||
maxTags={10}
|
</span>
|
||||||
size="sm"
|
<TagDisplay
|
||||||
showColors={true}
|
tags={tags}
|
||||||
showDot={false}
|
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>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
{/* Barre de l'aperçu */}
|
{/* 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">
|
<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">
|
||||||
|
|||||||
@@ -101,6 +101,30 @@ export function TagInput({
|
|||||||
}
|
}
|
||||||
}, [inputValue, searchTags, clearSuggestions, hasLoadedPopularTags]);
|
}, [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 addTag = (tagName: string) => {
|
||||||
const trimmedTag = tagName.trim();
|
const trimmedTag = tagName.trim();
|
||||||
if (trimmedTag && !tags.includes(trimmedTag) && tags.length < maxTags) {
|
if (trimmedTag && !tags.includes(trimmedTag) && tags.length < maxTags) {
|
||||||
|
|||||||
262
src/components/ui/TaskSelector.tsx
Normal file
262
src/components/ui/TaskSelector.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
import { prisma } from '@/services/core/database';
|
import { prisma } from '@/services/core/database';
|
||||||
|
import { Task } from '@/lib/types';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
export interface Note {
|
export interface Note {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
taskId?: string; // Tâche associée à la note
|
||||||
|
task?: Task | null; // Objet Task complet
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
@@ -14,12 +18,14 @@ export interface CreateNoteData {
|
|||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
taskId?: string; // Tâche associée à la note
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateNoteData {
|
export interface UpdateNoteData {
|
||||||
title?: string;
|
title?: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
|
taskId?: string; // Tâche associée à la note
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,6 +33,68 @@ export interface UpdateNoteData {
|
|||||||
* Service pour la gestion des notes markdown
|
* Service pour la gestion des notes markdown
|
||||||
*/
|
*/
|
||||||
export class NotesService {
|
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
|
* Récupère toutes les notes d'un utilisateur
|
||||||
*/
|
*/
|
||||||
@@ -39,12 +107,24 @@ export class NotesService {
|
|||||||
tag: true,
|
tag: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
task: {
|
||||||
|
include: {
|
||||||
|
taskTags: {
|
||||||
|
include: {
|
||||||
|
tag: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
primaryTag: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
orderBy: { updatedAt: 'desc' },
|
orderBy: { updatedAt: 'desc' },
|
||||||
});
|
});
|
||||||
|
|
||||||
return notes.map((note) => ({
|
return notes.map((note) => ({
|
||||||
...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),
|
tags: note.noteTags.map((nt) => nt.tag.name),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -64,6 +144,16 @@ export class NotesService {
|
|||||||
tag: true,
|
tag: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
task: {
|
||||||
|
include: {
|
||||||
|
taskTags: {
|
||||||
|
include: {
|
||||||
|
tag: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
primaryTag: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -71,6 +161,8 @@ export class NotesService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...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),
|
tags: note.noteTags.map((nt) => nt.tag.name),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -84,6 +176,7 @@ export class NotesService {
|
|||||||
title: data.title,
|
title: data.title,
|
||||||
content: data.content,
|
content: data.content,
|
||||||
userId: data.userId,
|
userId: data.userId,
|
||||||
|
taskId: data.taskId, // Ajouter le taskId
|
||||||
noteTags: data.tags
|
noteTags: data.tags
|
||||||
? {
|
? {
|
||||||
create: data.tags.map((tagName) => ({
|
create: data.tags.map((tagName) => ({
|
||||||
@@ -103,11 +196,23 @@ export class NotesService {
|
|||||||
tag: true,
|
tag: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
task: {
|
||||||
|
include: {
|
||||||
|
taskTags: {
|
||||||
|
include: {
|
||||||
|
tag: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
primaryTag: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...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),
|
tags: note.noteTags.map((nt) => nt.tag.name),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -137,6 +242,7 @@ export class NotesService {
|
|||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
title?: string;
|
title?: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
|
taskId?: string;
|
||||||
noteTags?: {
|
noteTags?: {
|
||||||
deleteMany: Record<string, never>;
|
deleteMany: Record<string, never>;
|
||||||
create: Array<{
|
create: Array<{
|
||||||
@@ -159,6 +265,9 @@ export class NotesService {
|
|||||||
if (data.content !== undefined) {
|
if (data.content !== undefined) {
|
||||||
updateData.content = data.content;
|
updateData.content = data.content;
|
||||||
}
|
}
|
||||||
|
if (data.taskId !== undefined) {
|
||||||
|
updateData.taskId = data.taskId;
|
||||||
|
}
|
||||||
|
|
||||||
// Gérer les tags si fournis
|
// Gérer les tags si fournis
|
||||||
if (data.tags !== undefined) {
|
if (data.tags !== undefined) {
|
||||||
@@ -184,11 +293,23 @@ export class NotesService {
|
|||||||
tag: true,
|
tag: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
task: {
|
||||||
|
include: {
|
||||||
|
taskTags: {
|
||||||
|
include: {
|
||||||
|
tag: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
primaryTag: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...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),
|
tags: note.noteTags.map((nt) => nt.tag.name),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -226,7 +347,10 @@ export class NotesService {
|
|||||||
orderBy: { updatedAt: 'desc' },
|
orderBy: { updatedAt: 'desc' },
|
||||||
});
|
});
|
||||||
|
|
||||||
return notes;
|
return notes.map((note) => ({
|
||||||
|
...note,
|
||||||
|
taskId: note.taskId || undefined, // Convertir null en undefined
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user