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])
|
||||
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[]
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
@@ -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,10 +340,13 @@ 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">
|
||||
<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>
|
||||
@@ -348,6 +358,23 @@ export function MarkdownEditor({
|
||||
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,10 +383,14 @@ 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">
|
||||
<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
|
||||
@@ -373,6 +404,24 @@ export function MarkdownEditor({
|
||||
</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">
|
||||
<div className="flex items-center justify-between relative z-10">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
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 { 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
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user