From c5a7d164254b29a38a04d1987d2077d4d767a311 Mon Sep 17 00:00:00 2001
From: Julien Froidefond
Date: Sun, 14 Sep 2025 16:44:22 +0200
Subject: [PATCH] feat: complete tag management and UI integration - Marked
multiple tasks as completed in TODO.md related to tag management features. -
Replaced manual tag input with `TagInput` component in `CreateTaskForm`,
`EditTaskForm`, and `QuickAddTask` for better UX. - Updated `TaskCard` to
display tags using `TagDisplay` with color support. - Enhanced `TasksService`
to manage task-tag relationships with CRUD operations. - Integrated tag
management into the global context for better accessibility across
components.
---
TODO.md | 23 +-
clients/tags-client.ts | 166 ++++++++++++
components/forms/CreateTaskForm.tsx | 67 +----
components/forms/EditTaskForm.tsx | 67 +----
components/forms/TagForm.tsx | 201 +++++++++++++++
components/kanban/Board.tsx | 1 +
components/kanban/QuickAddTask.tsx | 50 +---
components/kanban/TaskCard.tsx | 29 +--
components/ui/Header.tsx | 45 +++-
components/ui/TagDisplay.tsx | 157 ++++++++++++
components/ui/TagInput.tsx | 184 ++++++++++++++
components/ui/TagList.tsx | 91 +++++++
hooks/useTags.ts | 223 ++++++++++++++++
hooks/useTasks.ts | 3 -
.../migration.sql | 50 ++++
prisma/migrations/migration_lock.toml | 3 +
prisma/schema.prisma | 1 -
scripts/reset-database.ts | 11 +-
scripts/seed-data.ts | 3 +-
scripts/seed-tags.ts | 35 +++
services/tags.ts | 237 +++++++++++++++++
services/tasks.ts | 119 +++++++--
src/app/api/tags/[id]/route.ts | 144 +++++++++++
src/app/api/tags/route.ts | 100 ++++++++
src/app/tags/TagsPageClient.tsx | 240 ++++++++++++++++++
src/app/tags/page.tsx | 11 +
src/contexts/TasksContext.tsx | 18 +-
27 files changed, 2055 insertions(+), 224 deletions(-)
create mode 100644 clients/tags-client.ts
create mode 100644 components/forms/TagForm.tsx
create mode 100644 components/ui/TagDisplay.tsx
create mode 100644 components/ui/TagInput.tsx
create mode 100644 components/ui/TagList.tsx
create mode 100644 hooks/useTags.ts
create mode 100644 prisma/migrations/20250914143611_init_without_tags_json/migration.sql
create mode 100644 prisma/migrations/migration_lock.toml
create mode 100644 scripts/seed-tags.ts
create mode 100644 services/tags.ts
create mode 100644 src/app/api/tags/[id]/route.ts
create mode 100644 src/app/api/tags/route.ts
create mode 100644 src/app/tags/TagsPageClient.tsx
create mode 100644 src/app/tags/page.tsx
diff --git a/TODO.md b/TODO.md
index 4207b6e..e91e7e6 100644
--- a/TODO.md
+++ b/TODO.md
@@ -47,18 +47,27 @@
- [x] Validation des formulaires et gestion d'erreurs
### 2.4 Gestion des tags
-- [ ] Créer/éditer des tags avec sélecteur de couleur
-- [ ] Autocomplete pour les tags existants
-- [ ] Suppression de tags (avec vérification des dépendances)
-- [ ] Affichage des tags avec couleurs personnalisées
-- [ ] Filtrage par tags
+- [x] Créer/éditer des tags avec sélecteur de couleur
+- [x] Autocomplete pour les tags existants
+- [x] Suppression de tags (avec vérification des dépendances)
+- [x] Affichage des tags avec couleurs personnalisées
+- [x] Service tags avec CRUD complet (Prisma)
+- [x] API routes /api/tags avec validation
+- [x] Client HTTP et hook useTags
+- [x] Composants UI (TagInput, TagDisplay, TagForm)
+- [x] Intégration dans les formulaires (TagInput avec autocomplete)
+- [x] Intégration dans les TaskCards (TagDisplay avec couleurs)
+- [x] Contexte global pour partager les tags
+- [x] Page de gestion des tags (/tags) avec interface complète
+- [x] Navigation dans le Header (Kanban ↔ Tags)
+- [ ] Filtrage par tags (intégration dans Kanban)
### 2.5 Clients HTTP et hooks
- [x] `clients/tasks-client.ts` - Client pour les tâches (CRUD complet)
-- [ ] `clients/tags-client.ts` - Client pour les tags
+- [x] `clients/tags-client.ts` - Client pour les tags
- [x] `clients/base/http-client.ts` - Client HTTP de base
- [x] `hooks/useTasks.ts` - Hook pour la gestion des tâches (CRUD complet)
-- [ ] `hooks/useTags.ts` - Hook pour la gestion des tags
+- [x] `hooks/useTags.ts` - Hook pour la gestion des tags
- [ ] `hooks/useKanban.ts` - Hook pour drag & drop
- [x] Gestion des erreurs et loading states
- [x] Architecture SSR + hydratation client optimisée
diff --git a/clients/tags-client.ts b/clients/tags-client.ts
new file mode 100644
index 0000000..9b2972a
--- /dev/null
+++ b/clients/tags-client.ts
@@ -0,0 +1,166 @@
+import { HttpClient } from './base/http-client';
+import { Tag, ApiResponse } from '@/lib/types';
+
+// Types pour les requêtes
+export interface CreateTagData {
+ name: string;
+ color: string;
+}
+
+export interface UpdateTagData {
+ tagId: string;
+ name?: string;
+ color?: string;
+}
+
+export interface TagFilters {
+ q?: string; // Recherche par nom
+ popular?: boolean; // Tags les plus utilisés
+ limit?: number; // Limite de résultats
+}
+
+// Types pour les réponses
+export interface TagsResponse {
+ data: Tag[];
+ message: string;
+}
+
+export interface TagResponse {
+ data: Tag;
+ message: string;
+}
+
+export interface PopularTag extends Tag {
+ usage: number;
+}
+
+export interface PopularTagsResponse {
+ data: PopularTag[];
+ message: string;
+}
+
+/**
+ * Client HTTP pour la gestion des tags
+ */
+export class TagsClient extends HttpClient {
+ constructor() {
+ super('/api/tags');
+ }
+
+ /**
+ * Récupère tous les tags
+ */
+ async getTags(filters?: TagFilters): Promise {
+ const params: Record = {};
+
+ if (filters?.q) {
+ params.q = filters.q;
+ }
+
+ if (filters?.popular) {
+ params.popular = 'true';
+ }
+
+ if (filters?.limit) {
+ params.limit = filters.limit.toString();
+ }
+
+ return this.get('', Object.keys(params).length > 0 ? params : undefined);
+ }
+
+ /**
+ * Récupère les tags populaires (les plus utilisés)
+ */
+ async getPopularTags(limit: number = 10): Promise {
+ return this.get('', { popular: 'true', limit: limit.toString() });
+ }
+
+ /**
+ * Recherche des tags par nom (pour autocomplete)
+ */
+ async searchTags(query: string, limit: number = 10): Promise {
+ return this.get('', { q: query, limit: limit.toString() });
+ }
+
+ /**
+ * Récupère un tag par son ID
+ */
+ async getTagById(id: string): Promise {
+ return this.get(`/${id}`);
+ }
+
+ /**
+ * Crée un nouveau tag
+ */
+ async createTag(data: CreateTagData): Promise {
+ return this.post('', data);
+ }
+
+ /**
+ * Met à jour un tag
+ */
+ async updateTag(data: UpdateTagData): Promise {
+ const { tagId, ...updates } = data;
+ return this.patch(`/${tagId}`, updates);
+ }
+
+ /**
+ * Supprime un tag
+ */
+ async deleteTag(id: string): Promise> {
+ return this.delete>(`/${id}`);
+ }
+
+ /**
+ * Valide le format d'une couleur hexadécimale
+ */
+ static isValidColor(color: string): boolean {
+ return /^#[0-9A-F]{6}$/i.test(color);
+ }
+
+ /**
+ * Génère une couleur aléatoire pour un nouveau tag
+ */
+ static generateRandomColor(): string {
+ const colors = [
+ '#3B82F6', // Blue
+ '#EF4444', // Red
+ '#10B981', // Green
+ '#F59E0B', // Yellow
+ '#8B5CF6', // Purple
+ '#EC4899', // Pink
+ '#06B6D4', // Cyan
+ '#84CC16', // Lime
+ '#F97316', // Orange
+ '#6366F1', // Indigo
+ ];
+
+ return colors[Math.floor(Math.random() * colors.length)];
+ }
+
+ /**
+ * Valide les données d'un tag
+ */
+ static validateTagData(data: Partial): string[] {
+ const errors: string[] = [];
+
+ if (!data.name || typeof data.name !== 'string') {
+ errors.push('Le nom du tag est requis');
+ } else if (data.name.trim().length === 0) {
+ errors.push('Le nom du tag ne peut pas être vide');
+ } else if (data.name.length > 50) {
+ errors.push('Le nom du tag ne peut pas dépasser 50 caractères');
+ }
+
+ if (!data.color || typeof data.color !== 'string') {
+ errors.push('La couleur du tag est requise');
+ } else if (!this.isValidColor(data.color)) {
+ errors.push('La couleur doit être au format hexadécimal (#RRGGBB)');
+ }
+
+ return errors;
+ }
+}
+
+// Instance singleton
+export const tagsClient = new TagsClient();
\ No newline at end of file
diff --git a/components/forms/CreateTaskForm.tsx b/components/forms/CreateTaskForm.tsx
index 4617af2..1fef417 100644
--- a/components/forms/CreateTaskForm.tsx
+++ b/components/forms/CreateTaskForm.tsx
@@ -4,7 +4,7 @@ import { useState } from 'react';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
-import { Badge } from '@/components/ui/Badge';
+import { TagInput } from '@/components/ui/TagInput';
import { TaskPriority, TaskStatus } from '@/lib/types';
import { CreateTaskData } from '@/clients/tasks-client';
@@ -25,7 +25,6 @@ export function CreateTaskForm({ isOpen, onClose, onSubmit, loading = false }: C
dueDate: undefined
});
- const [tagInput, setTagInput] = useState('');
const [errors, setErrors] = useState>({});
const validateForm = (): boolean => {
@@ -69,35 +68,10 @@ export function CreateTaskForm({ isOpen, onClose, onSubmit, loading = false }: C
tags: [],
dueDate: undefined
});
- setTagInput('');
setErrors({});
onClose();
};
- const addTag = () => {
- const tag = tagInput.trim();
- if (tag && !formData.tags?.includes(tag)) {
- setFormData((prev: CreateTaskData) => ({
- ...prev,
- tags: [...(prev.tags || []), tag]
- }));
- setTagInput('');
- }
- };
-
- const removeTag = (tagToRemove: string) => {
- setFormData((prev: CreateTaskData) => ({
- ...prev,
- tags: prev.tags?.filter((tag: string) => tag !== tagToRemove) || []
- }));
- };
-
- const handleTagKeyPress = (e: React.KeyboardEvent) => {
- if (e.key === 'Enter') {
- e.preventDefault();
- addTag();
- }
- };
return (
@@ -188,39 +162,12 @@ export function CreateTaskForm({ isOpen, onClose, onSubmit, loading = false }: C
Tags
-
- setTagInput(e.target.value)}
- onKeyPress={handleTagKeyPress}
- placeholder="Ajouter un tag..."
- disabled={loading}
- className="flex-1"
- />
-
-
-
- {formData.tags && formData.tags.length > 0 && (
-
- {formData.tags.map((tag: string, index: number) => (
- removeTag(tag)}
- >
- {tag} ✕
-
- ))}
-
- )}
+ setFormData(prev => ({ ...prev, tags }))}
+ placeholder="Ajouter des tags..."
+ maxTags={10}
+ />
{/* Actions */}
diff --git a/components/forms/EditTaskForm.tsx b/components/forms/EditTaskForm.tsx
index 5dc2634..85e6d18 100644
--- a/components/forms/EditTaskForm.tsx
+++ b/components/forms/EditTaskForm.tsx
@@ -4,7 +4,7 @@ import { useState, useEffect } from 'react';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
-import { Badge } from '@/components/ui/Badge';
+import { TagInput } from '@/components/ui/TagInput';
import { Task, TaskPriority, TaskStatus } from '@/lib/types';
import { UpdateTaskData } from '@/clients/tasks-client';
@@ -26,7 +26,6 @@ export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false
dueDate: undefined
});
- const [tagInput, setTagInput] = useState('');
const [errors, setErrors] = useState>({});
// Pré-remplir le formulaire quand la tâche change
@@ -79,35 +78,10 @@ export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false
};
const handleClose = () => {
- setTagInput('');
setErrors({});
onClose();
};
- const addTag = () => {
- const tag = tagInput.trim();
- if (tag && !formData.tags?.includes(tag)) {
- setFormData(prev => ({
- ...prev,
- tags: [...(prev.tags || []), tag]
- }));
- setTagInput('');
- }
- };
-
- const removeTag = (tagToRemove: string) => {
- setFormData(prev => ({
- ...prev,
- tags: prev.tags?.filter((tag: string) => tag !== tagToRemove) || []
- }));
- };
-
- const handleTagKeyPress = (e: React.KeyboardEvent) => {
- if (e.key === 'Enter') {
- e.preventDefault();
- addTag();
- }
- };
if (!task) return null;
@@ -200,39 +174,12 @@ export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false
Tags
-
- setTagInput(e.target.value)}
- onKeyPress={handleTagKeyPress}
- placeholder="Ajouter un tag..."
- disabled={loading}
- className="flex-1"
- />
-
-
-
- {formData.tags && formData.tags.length > 0 && (
-
- {formData.tags.map((tag: string, index: number) => (
- removeTag(tag)}
- >
- {tag} ✕
-
- ))}
-
- )}
+ setFormData(prev => ({ ...prev, tags }))}
+ placeholder="Ajouter des tags..."
+ maxTags={10}
+ />
{/* Actions */}
diff --git a/components/forms/TagForm.tsx b/components/forms/TagForm.tsx
new file mode 100644
index 0000000..df9b6ba
--- /dev/null
+++ b/components/forms/TagForm.tsx
@@ -0,0 +1,201 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { Tag } from '@/lib/types';
+import { TagsClient } from '@/clients/tags-client';
+import { Modal } from '@/components/ui/Modal';
+import { Input } from '@/components/ui/Input';
+import { Button } from '@/components/ui/Button';
+
+interface TagFormProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onSubmit: (data: { name: string; color: string }) => Promise;
+ tag?: Tag | null; // Si fourni, mode édition
+ loading?: boolean;
+}
+
+const PRESET_COLORS = [
+ '#3B82F6', // Blue
+ '#EF4444', // Red
+ '#10B981', // Green
+ '#F59E0B', // Yellow
+ '#8B5CF6', // Purple
+ '#EC4899', // Pink
+ '#06B6D4', // Cyan
+ '#84CC16', // Lime
+ '#F97316', // Orange
+ '#6366F1', // Indigo
+ '#14B8A6', // Teal
+ '#F43F5E', // Rose
+];
+
+export function TagForm({ isOpen, onClose, onSubmit, tag, loading = false }: TagFormProps) {
+ const [formData, setFormData] = useState({
+ name: '',
+ color: '#3B82F6'
+ });
+ const [errors, setErrors] = useState([]);
+
+ // Pré-remplir le formulaire en mode édition
+ useEffect(() => {
+ if (tag) {
+ setFormData({
+ name: tag.name,
+ color: tag.color
+ });
+ } else {
+ setFormData({
+ name: '',
+ color: TagsClient.generateRandomColor()
+ });
+ }
+ setErrors([]);
+ }, [tag, isOpen]);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+
+ // Validation
+ const validationErrors = TagsClient.validateTagData(formData);
+ if (validationErrors.length > 0) {
+ setErrors(validationErrors);
+ return;
+ }
+
+ try {
+ await onSubmit(formData);
+ onClose();
+ } catch (error) {
+ setErrors([error instanceof Error ? error.message : 'Erreur inconnue']);
+ }
+ };
+
+ const handleColorSelect = (color: string) => {
+ setFormData(prev => ({ ...prev, color }));
+ setErrors([]);
+ };
+
+ const handleCustomColorChange = (e: React.ChangeEvent) => {
+ setFormData(prev => ({ ...prev, color: e.target.value }));
+ setErrors([]);
+ };
+
+ return (
+
+
+
+ );
+}
diff --git a/components/kanban/Board.tsx b/components/kanban/Board.tsx
index e11ec9b..4b745de 100644
--- a/components/kanban/Board.tsx
+++ b/components/kanban/Board.tsx
@@ -118,6 +118,7 @@ export function KanbanBoard({ tasks, onCreateTask, onDeleteTask, onEditTask, onU
return (
('title');
const titleRef = useRef(null);
@@ -53,7 +52,6 @@ export function QuickAddTask({ status, onSubmit, onCancel }: QuickAddTaskProps)
tags: [],
dueDate: undefined
});
- setTagInput('');
setActiveField('title');
setIsSubmitting(false);
titleRef.current?.focus();
@@ -73,9 +71,8 @@ export function QuickAddTask({ status, onSubmit, onCancel }: QuickAddTaskProps)
if (field === 'title' && formData.title.trim()) {
console.log('Calling handleSubmit from title');
handleSubmit();
- } else if (field === 'tags' && tagInput.trim()) {
- console.log('Adding tag');
- addTag();
+ } else if (field === 'tags') {
+ // TagInput gère ses propres événements Enter
} else if (formData.title.trim()) {
// Permettre création depuis n'importe quel champ si titre rempli
console.log('Calling handleSubmit from other field');
@@ -95,21 +92,10 @@ export function QuickAddTask({ status, onSubmit, onCancel }: QuickAddTaskProps)
// Laisser passer tous les autres événements (y compris les raccourcis système)
};
- const addTag = () => {
- const tag = tagInput.trim();
- if (tag && !formData.tags?.includes(tag)) {
- setFormData(prev => ({
- ...prev,
- tags: [...(prev.tags || []), tag]
- }));
- setTagInput('');
- }
- };
-
- const removeTag = (tagToRemove: string) => {
+ const handleTagsChange = (tags: string[]) => {
setFormData(prev => ({
...prev,
- tags: prev.tags?.filter(tag => tag !== tagToRemove) || []
+ tags
}));
};
@@ -171,28 +157,12 @@ export function QuickAddTask({ status, onSubmit, onCancel }: QuickAddTaskProps)
{/* Tags */}
-
- {formData.tags && formData.tags.map((tag: string, index: number) => (
- removeTag(tag)}
- >
- {tag} ✕
-
- ))}
-
-
setTagInput(e.target.value)}
- onKeyDown={(e) => handleKeyDown(e, 'tags')}
- onFocus={() => setActiveField('tags')}
+
diff --git a/components/kanban/TaskCard.tsx b/components/kanban/TaskCard.tsx
index e0e09f4..79e3156 100644
--- a/components/kanban/TaskCard.tsx
+++ b/components/kanban/TaskCard.tsx
@@ -4,6 +4,8 @@ import { formatDistanceToNow } from 'date-fns';
import { fr } from 'date-fns/locale';
import { Card } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
+import { TagDisplay } from '@/components/ui/TagDisplay';
+import { useTasksContext } from '@/contexts/TasksContext';
import { useDraggable } from '@dnd-kit/core';
interface TaskCardProps {
@@ -16,6 +18,7 @@ interface TaskCardProps {
export function TaskCard({ task, onDelete, onEdit, onUpdateTitle }: TaskCardProps) {
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [editTitle, setEditTitle] = useState(task.title);
+ const { tags: availableTags } = useTasksContext();
// Configuration du draggable
const {
@@ -170,24 +173,16 @@ export function TaskCard({ task, onDelete, onEdit, onUpdateTitle }: TaskCardProp
)}
- {/* Tags avec composant Badge */}
+ {/* Tags avec couleurs */}
{task.tags && task.tags.length > 0 && (
-
- {task.tags.slice(0, 3).map((tag, index) => (
-
- {tag}
-
- ))}
- {task.tags.length > 3 && (
-
- +{task.tags.length - 3}
-
- )}
+
+
)}
diff --git a/components/ui/Header.tsx b/components/ui/Header.tsx
index f1a198d..9388936 100644
--- a/components/ui/Header.tsx
+++ b/components/ui/Header.tsx
@@ -1,4 +1,5 @@
import { Card, CardContent } from '@/components/ui/Card';
+import Link from 'next/link';
interface HeaderProps {
title: string;
@@ -19,20 +20,38 @@ export function Header({ title, subtitle, stats, syncing = false }: HeaderProps)
{/* Titre tech avec glow */}
-
-
-
-
- {title}
-
-
- {subtitle} {syncing && '• Synchronisation...'}
-
+
+
+
+
+
+ {title}
+
+
+ {subtitle} {syncing && '• Synchronisation...'}
+
+
+
+ {/* Navigation */}
+
{/* Stats tech dashboard */}
diff --git a/components/ui/TagDisplay.tsx b/components/ui/TagDisplay.tsx
new file mode 100644
index 0000000..b3eff64
--- /dev/null
+++ b/components/ui/TagDisplay.tsx
@@ -0,0 +1,157 @@
+'use client';
+
+import { Tag } from '@/lib/types';
+import { Badge } from './Badge';
+
+interface TagDisplayProps {
+ tags: string[];
+ availableTags?: Tag[]; // Tags avec couleurs depuis la DB
+ size?: 'sm' | 'md' | 'lg';
+ maxTags?: number;
+ showColors?: boolean;
+ onClick?: (tagName: string) => void;
+ className?: string;
+}
+
+export function TagDisplay({
+ tags,
+ availableTags = [],
+ size = 'sm',
+ maxTags,
+ showColors = true,
+ onClick,
+ className = ""
+}: TagDisplayProps) {
+ if (!tags || tags.length === 0) {
+ return null;
+ }
+
+ const displayTags = maxTags ? tags.slice(0, maxTags) : tags;
+ const remainingCount = maxTags && tags.length > maxTags ? tags.length - maxTags : 0;
+
+ const getTagColor = (tagName: string): string => {
+ if (!showColors) return '#6b7280'; // gray-500
+
+ const tag = availableTags.find(t => t.name === tagName);
+ return tag?.color || '#6b7280';
+ };
+
+ const sizeClasses = {
+ sm: 'text-xs px-2 py-0.5',
+ md: 'text-sm px-2 py-1',
+ lg: 'text-base px-3 py-1.5'
+ };
+
+ return (
+
+ {displayTags.map((tagName, index) => {
+ const color = getTagColor(tagName);
+
+ return (
+
onClick?.(tagName)}
+ className={`inline-flex items-center gap-1.5 rounded-full border transition-colors ${sizeClasses[size]} ${
+ onClick ? 'cursor-pointer hover:opacity-80' : ''
+ }`}
+ style={{
+ backgroundColor: showColors ? `${color}20` : undefined,
+ borderColor: showColors ? `${color}60` : undefined,
+ color: showColors ? color : undefined
+ }}
+ >
+ {showColors && (
+
+ )}
+
{tagName}
+
+ );
+ })}
+
+ {remainingCount > 0 && (
+
+ +{remainingCount}
+
+ )}
+
+ );
+}
+
+interface TagListProps {
+ tags: Tag[];
+ onTagClick?: (tag: Tag) => void;
+ onTagEdit?: (tag: Tag) => void;
+ onTagDelete?: (tag: Tag) => void;
+ className?: string;
+}
+
+/**
+ * Composant pour afficher une liste complète de tags avec actions
+ */
+export function TagList({
+ tags,
+ onTagClick,
+ onTagEdit,
+ onTagDelete,
+ className = ""
+}: TagListProps) {
+ if (!tags || tags.length === 0) {
+ return (
+
+
🏷️
+
Aucun tag trouvé
+
+ );
+ }
+
+ return (
+
+ {tags.map((tag) => (
+
+
onTagClick?.(tag)}
+ >
+
+
{tag.name}
+
+
+
+ {onTagEdit && (
+
+ )}
+
+ {onTagDelete && (
+
+ )}
+
+
+ ))}
+
+ );
+}
diff --git a/components/ui/TagInput.tsx b/components/ui/TagInput.tsx
new file mode 100644
index 0000000..899c14d
--- /dev/null
+++ b/components/ui/TagInput.tsx
@@ -0,0 +1,184 @@
+'use client';
+
+import { useState, useRef, useEffect } from 'react';
+import { Tag } from '@/lib/types';
+import { useTagsAutocomplete } from '@/hooks/useTags';
+import { Badge } from './Badge';
+
+interface TagInputProps {
+ tags: string[];
+ onChange: (tags: string[]) => void;
+ placeholder?: string;
+ maxTags?: number;
+ className?: string;
+}
+
+export function TagInput({
+ tags,
+ onChange,
+ placeholder = "Ajouter des tags...",
+ maxTags = 10,
+ className = ""
+}: TagInputProps) {
+ const [inputValue, setInputValue] = useState('');
+ const [showSuggestions, setShowSuggestions] = useState(false);
+ const [selectedIndex, setSelectedIndex] = useState(-1);
+ const inputRef = useRef
(null);
+ const suggestionsRef = useRef(null);
+
+ const { suggestions, loading, searchTags, clearSuggestions } = useTagsAutocomplete();
+
+ // Rechercher des suggestions quand l'input change
+ useEffect(() => {
+ if (inputValue.trim()) {
+ searchTags(inputValue);
+ setShowSuggestions(true);
+ setSelectedIndex(-1);
+ } else {
+ clearSuggestions();
+ setShowSuggestions(false);
+ }
+ }, [inputValue, searchTags, clearSuggestions]);
+
+ const addTag = (tagName: string) => {
+ const trimmedTag = tagName.trim();
+ if (trimmedTag && !tags.includes(trimmedTag) && tags.length < maxTags) {
+ onChange([...tags, trimmedTag]);
+ }
+ setInputValue('');
+ setShowSuggestions(false);
+ setSelectedIndex(-1);
+ };
+
+ const removeTag = (tagToRemove: string) => {
+ onChange(tags.filter(tag => tag !== tagToRemove));
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ if (selectedIndex >= 0 && suggestions[selectedIndex]) {
+ addTag(suggestions[selectedIndex].name);
+ } else if (inputValue.trim()) {
+ addTag(inputValue);
+ }
+ } else if (e.key === 'Escape') {
+ setShowSuggestions(false);
+ setSelectedIndex(-1);
+ } else if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ setSelectedIndex(prev =>
+ prev < suggestions.length - 1 ? prev + 1 : prev
+ );
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ setSelectedIndex(prev => prev > 0 ? prev - 1 : -1);
+ } else if (e.key === 'Backspace' && !inputValue && tags.length > 0) {
+ // Supprimer le dernier tag si l'input est vide
+ removeTag(tags[tags.length - 1]);
+ }
+ };
+
+ const handleSuggestionClick = (tag: Tag) => {
+ addTag(tag.name);
+ };
+
+ const handleBlur = (e: React.FocusEvent) => {
+ // Délai pour permettre le clic sur une suggestion
+ setTimeout(() => {
+ if (!suggestionsRef.current?.contains(e.relatedTarget as Node)) {
+ setShowSuggestions(false);
+ setSelectedIndex(-1);
+ }
+ }, 150);
+ };
+
+ return (
+
+ {/* Container des tags et input */}
+
+
+ {/* Tags existants */}
+ {tags.map((tag, index) => (
+
+ {tag}
+
+
+ ))}
+
+ {/* Input pour nouveau tag */}
+ {tags.length < maxTags && (
+ setInputValue(e.target.value)}
+ onKeyDown={handleKeyDown}
+ onBlur={handleBlur}
+ onFocus={() => inputValue && setShowSuggestions(true)}
+ placeholder={tags.length === 0 ? placeholder : ""}
+ className="flex-1 min-w-[120px] bg-transparent border-none outline-none text-slate-100 placeholder-slate-400 text-sm"
+ />
+ )}
+
+
+
+ {/* Suggestions dropdown */}
+ {showSuggestions && (suggestions.length > 0 || loading) && (
+
+ {loading ? (
+
+ Recherche...
+
+ ) : (
+ suggestions.map((tag, index) => (
+
+ ))
+ )}
+
+ )}
+
+ {/* Indicateur de limite */}
+ {tags.length >= maxTags && (
+
+ Limite de {maxTags} tags atteinte
+
+ )}
+
+ );
+}
diff --git a/components/ui/TagList.tsx b/components/ui/TagList.tsx
new file mode 100644
index 0000000..48edffe
--- /dev/null
+++ b/components/ui/TagList.tsx
@@ -0,0 +1,91 @@
+import { Tag } from '@/lib/types';
+import { Button } from '@/components/ui/Button';
+
+interface TagListProps {
+ tags: (Tag & { usage?: number })[];
+ onTagEdit?: (tag: Tag) => void;
+ onTagDelete?: (tag: Tag) => void;
+ showActions?: boolean;
+ showUsage?: boolean;
+}
+
+export function TagList({
+ tags,
+ onTagEdit,
+ onTagDelete,
+ showActions = true
+}: TagListProps) {
+ if (tags.length === 0) {
+ return (
+
+
🏷️
+
Aucun tag trouvé
+
Créez votre premier tag pour commencer
+
+ );
+ }
+
+ return (
+
+ {tags.map((tag) => (
+
+ {/* Contenu principal */}
+
+
+
+
+
+ {tag.name}
+
+ {tag.usage !== undefined && (
+
+ {tag.usage}
+
+ )}
+
+
+
+
+
+ {/* Actions (apparaissent au hover) */}
+ {showActions && (onTagEdit || onTagDelete) && (
+
+ {onTagEdit && (
+
+ )}
+ {onTagDelete && (
+
+ )}
+
+ )}
+
+ {/* Indicateur de couleur en bas */}
+
+
+ ))}
+
+ );
+}
diff --git a/hooks/useTags.ts b/hooks/useTags.ts
new file mode 100644
index 0000000..6bf001e
--- /dev/null
+++ b/hooks/useTags.ts
@@ -0,0 +1,223 @@
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+import { tagsClient, TagFilters, CreateTagData, UpdateTagData, TagsClient } from '@/clients/tags-client';
+import { Tag } from '@/lib/types';
+
+interface UseTagsState {
+ tags: Tag[];
+ popularTags: Array;
+ loading: boolean;
+ error: string | null;
+}
+
+interface UseTagsActions {
+ refreshTags: () => Promise;
+ searchTags: (query: string, limit?: number) => Promise;
+ createTag: (data: CreateTagData) => Promise;
+ updateTag: (data: UpdateTagData) => Promise;
+ deleteTag: (tagId: string) => Promise;
+ getPopularTags: (limit?: number) => Promise;
+ setFilters: (filters: TagFilters) => void;
+}
+
+/**
+ * Hook pour la gestion des tags
+ */
+export function useTags(
+ initialFilters?: TagFilters
+): UseTagsState & UseTagsActions {
+ const [state, setState] = useState({
+ tags: [],
+ popularTags: [],
+ loading: false,
+ error: null
+ });
+
+ const [filters, setFilters] = useState(initialFilters || {});
+
+ /**
+ * Récupère les tags depuis l'API
+ */
+ const refreshTags = useCallback(async () => {
+ setState(prev => ({ ...prev, loading: true, error: null }));
+
+ try {
+ const response = await tagsClient.getTags(filters);
+
+ setState(prev => ({
+ ...prev,
+ tags: response.data,
+ loading: false
+ }));
+ } catch (error) {
+ setState(prev => ({
+ ...prev,
+ loading: false,
+ error: error instanceof Error ? error.message : 'Erreur inconnue'
+ }));
+ }
+ }, [filters]);
+
+ /**
+ * Recherche des tags par nom (pour autocomplete)
+ */
+ const searchTags = useCallback(async (query: string, limit: number = 10): Promise => {
+ try {
+ const response = await tagsClient.searchTags(query, limit);
+ return response.data;
+ } catch (error) {
+ console.error('Erreur lors de la recherche de tags:', error);
+ return [];
+ }
+ }, []);
+
+ /**
+ * Récupère les tags populaires
+ */
+ const getPopularTags = useCallback(async (limit: number = 10) => {
+ setState(prev => ({ ...prev, loading: true, error: null }));
+
+ try {
+ const response = await tagsClient.getPopularTags(limit);
+
+ setState(prev => ({
+ ...prev,
+ popularTags: response.data,
+ loading: false
+ }));
+ } catch (error) {
+ setState(prev => ({
+ ...prev,
+ loading: false,
+ error: error instanceof Error ? error.message : 'Erreur inconnue'
+ }));
+ }
+ }, []);
+
+ /**
+ * Crée un nouveau tag
+ */
+ const createTag = useCallback(async (data: CreateTagData): Promise => {
+ setState(prev => ({ ...prev, loading: true, error: null }));
+
+ try {
+ // Validation côté client
+ const errors = TagsClient.validateTagData(data);
+ if (errors.length > 0) {
+ throw new Error(errors[0]);
+ }
+
+ const response = await tagsClient.createTag(data);
+
+ // Rafraîchir la liste après création
+ await refreshTags();
+
+ return response.data;
+ } catch (error) {
+ setState(prev => ({
+ ...prev,
+ loading: false,
+ error: error instanceof Error ? error.message : 'Erreur lors de la création'
+ }));
+ return null;
+ }
+ }, [refreshTags]);
+
+ /**
+ * Met à jour un tag
+ */
+ const updateTag = useCallback(async (data: UpdateTagData): Promise => {
+ setState(prev => ({ ...prev, loading: true, error: null }));
+
+ try {
+ const response = await tagsClient.updateTag(data);
+
+ // Rafraîchir la liste après mise à jour
+ await refreshTags();
+
+ return response.data;
+ } catch (error) {
+ setState(prev => ({
+ ...prev,
+ loading: false,
+ error: error instanceof Error ? error.message : 'Erreur lors de la mise à jour'
+ }));
+ return null;
+ }
+ }, [refreshTags]);
+
+ /**
+ * Supprime un tag
+ */
+ const deleteTag = useCallback(async (tagId: string): Promise => {
+ setState(prev => ({ ...prev, loading: true, error: null }));
+
+ try {
+ await tagsClient.deleteTag(tagId);
+
+ // Rafraîchir la liste après suppression
+ await refreshTags();
+ } catch (error) {
+ setState(prev => ({
+ ...prev,
+ loading: false,
+ error: error instanceof Error ? error.message : 'Erreur lors de la suppression'
+ }));
+ throw error; // Re-throw pour que l'UI puisse gérer l'erreur
+ }
+ }, [refreshTags]);
+
+ // Charger les tags au montage et quand les filtres changent
+ useEffect(() => {
+ refreshTags();
+ }, [refreshTags]);
+
+ return {
+ ...state,
+ refreshTags,
+ searchTags,
+ createTag,
+ updateTag,
+ deleteTag,
+ getPopularTags,
+ setFilters
+ };
+}
+
+/**
+ * Hook simplifié pour l'autocomplete des tags
+ */
+export function useTagsAutocomplete() {
+ const [suggestions, setSuggestions] = useState([]);
+ const [loading, setLoading] = useState(false);
+
+ const searchTags = useCallback(async (query: string) => {
+ if (!query.trim()) {
+ setSuggestions([]);
+ return;
+ }
+
+ setLoading(true);
+ try {
+ const response = await tagsClient.searchTags(query, 5);
+ setSuggestions(response.data);
+ } catch (error) {
+ console.error('Erreur lors de la recherche de tags:', error);
+ setSuggestions([]);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ const clearSuggestions = useCallback(() => {
+ setSuggestions([]);
+ }, []);
+
+ return {
+ suggestions,
+ loading,
+ searchTags,
+ clearSuggestions
+ };
+}
diff --git a/hooks/useTasks.ts b/hooks/useTasks.ts
index 6e793d6..cefc23c 100644
--- a/hooks/useTasks.ts
+++ b/hooks/useTasks.ts
@@ -162,9 +162,6 @@ export function useTasks(
// 3. Appel API en arrière-plan
try {
- // Délai artificiel pour voir l'indicateur de sync (à supprimer en prod)
- await new Promise(resolve => setTimeout(resolve, 1000));
-
const response = await tasksClient.updateTask(data);
// Si l'API retourne des données différentes, on met à jour
diff --git a/prisma/migrations/20250914143611_init_without_tags_json/migration.sql b/prisma/migrations/20250914143611_init_without_tags_json/migration.sql
new file mode 100644
index 0000000..0553ae2
--- /dev/null
+++ b/prisma/migrations/20250914143611_init_without_tags_json/migration.sql
@@ -0,0 +1,50 @@
+-- CreateTable
+CREATE TABLE "tasks" (
+ "id" TEXT NOT NULL PRIMARY KEY,
+ "title" TEXT NOT NULL,
+ "description" TEXT,
+ "status" TEXT NOT NULL DEFAULT 'todo',
+ "priority" TEXT NOT NULL DEFAULT 'medium',
+ "source" TEXT NOT NULL,
+ "sourceId" TEXT,
+ "dueDate" DATETIME,
+ "completedAt" DATETIME,
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" DATETIME NOT NULL,
+ "jiraProject" TEXT,
+ "jiraKey" TEXT,
+ "assignee" TEXT
+);
+
+-- CreateTable
+CREATE TABLE "tags" (
+ "id" TEXT NOT NULL PRIMARY KEY,
+ "name" TEXT NOT NULL,
+ "color" TEXT NOT NULL DEFAULT '#6b7280'
+);
+
+-- CreateTable
+CREATE TABLE "task_tags" (
+ "taskId" TEXT NOT NULL,
+ "tagId" TEXT NOT NULL,
+
+ PRIMARY KEY ("taskId", "tagId"),
+ CONSTRAINT "task_tags_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "tasks" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT "task_tags_tagId_fkey" FOREIGN KEY ("tagId") REFERENCES "tags" ("id") ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+-- CreateTable
+CREATE TABLE "sync_logs" (
+ "id" TEXT NOT NULL PRIMARY KEY,
+ "source" TEXT NOT NULL,
+ "status" TEXT NOT NULL,
+ "message" TEXT,
+ "tasksSync" INTEGER NOT NULL DEFAULT 0,
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "tasks_source_sourceId_key" ON "tasks"("source", "sourceId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "tags_name_key" ON "tags"("name");
diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml
new file mode 100644
index 0000000..2a5a444
--- /dev/null
+++ b/prisma/migrations/migration_lock.toml
@@ -0,0 +1,3 @@
+# Please do not edit this file manually
+# It should be added in your version-control system (e.g., Git)
+provider = "sqlite"
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index c30ffb9..7164ce7 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -18,7 +18,6 @@ model Task {
priority String @default("medium")
source String // "reminders" | "jira"
sourceId String? // ID dans le système source
- tagsJson String @default("[]") // JSON string des tags pour compatibilité
dueDate DateTime?
completedAt DateTime?
createdAt DateTime @default(now())
diff --git a/scripts/reset-database.ts b/scripts/reset-database.ts
index e29670d..de9314c 100644
--- a/scripts/reset-database.ts
+++ b/scripts/reset-database.ts
@@ -48,6 +48,13 @@ async function resetDatabase() {
console.log('');
console.log('📋 Tâches restantes:');
const remainingTasks = await prisma.task.findMany({
+ include: {
+ taskTags: {
+ include: {
+ tag: true
+ }
+ }
+ },
orderBy: { createdAt: 'desc' }
});
@@ -59,7 +66,9 @@ async function resetDatabase() {
'cancelled': '❌'
}[task.status] || '❓';
- const tags = JSON.parse(task.tagsJson || '[]');
+ // Utiliser les relations TaskTag
+ const tags = task.taskTags ? task.taskTags.map(tt => tt.tag.name) : [];
+
const tagsStr = tags.length > 0 ? ` [${tags.join(', ')}]` : '';
console.log(` ${index + 1}. ${statusEmoji} ${task.title}${tagsStr}`);
diff --git a/scripts/seed-data.ts b/scripts/seed-data.ts
index e6c987e..d65478f 100644
--- a/scripts/seed-data.ts
+++ b/scripts/seed-data.ts
@@ -52,7 +52,8 @@ async function seedTestData() {
const priorityEmoji = {
'low': '🔵',
'medium': '🟡',
- 'high': '🔴'
+ 'high': '🔴',
+ 'urgent': '🚨'
}[task.priority];
console.log(` ${statusEmoji} ${priorityEmoji} ${task.title}`);
diff --git a/scripts/seed-tags.ts b/scripts/seed-tags.ts
new file mode 100644
index 0000000..4485237
--- /dev/null
+++ b/scripts/seed-tags.ts
@@ -0,0 +1,35 @@
+import { tagsService } from '../services/tags';
+
+async function seedTags() {
+ console.log('🏷️ Création des tags de test...');
+
+ const testTags = [
+ { name: 'Frontend', color: '#3B82F6' },
+ { name: 'Backend', color: '#EF4444' },
+ { name: 'Bug', color: '#F59E0B' },
+ { name: 'Feature', color: '#10B981' },
+ { name: 'Urgent', color: '#EC4899' },
+ { name: 'Design', color: '#8B5CF6' },
+ { name: 'API', color: '#06B6D4' },
+ { name: 'Database', color: '#84CC16' },
+ ];
+
+ for (const tagData of testTags) {
+ try {
+ const existing = await tagsService.getTagByName(tagData.name);
+ if (!existing) {
+ const tag = await tagsService.createTag(tagData);
+ console.log(`✅ Tag créé: ${tag.name} (${tag.color})`);
+ } else {
+ console.log(`⚠️ Tag existe déjà: ${tagData.name}`);
+ }
+ } catch (error) {
+ console.error(`❌ Erreur pour le tag ${tagData.name}:`, error);
+ }
+ }
+
+ console.log('🎉 Seeding des tags terminé !');
+}
+
+// Exécuter le script
+seedTags().catch(console.error);
diff --git a/services/tags.ts b/services/tags.ts
new file mode 100644
index 0000000..658494e
--- /dev/null
+++ b/services/tags.ts
@@ -0,0 +1,237 @@
+import { prisma } from './database';
+import { Tag } from '@/lib/types';
+
+/**
+ * Service pour la gestion des tags
+ */
+export const tagsService = {
+ /**
+ * Récupère tous les tags avec leur nombre d'utilisations
+ */
+ async getTags(): Promise<(Tag & { usage: number })[]> {
+ const tags = await prisma.tag.findMany({
+ include: {
+ _count: {
+ select: {
+ taskTags: true
+ }
+ }
+ },
+ orderBy: { name: 'asc' }
+ });
+
+ return tags.map(tag => ({
+ id: tag.id,
+ name: tag.name,
+ color: tag.color,
+ usage: tag._count.taskTags
+ }));
+ },
+
+ /**
+ * Récupère un tag par son ID
+ */
+ async getTagById(id: string): Promise {
+ const tag = await prisma.tag.findUnique({
+ where: { id }
+ });
+
+ if (!tag) return null;
+
+ return {
+ id: tag.id,
+ name: tag.name,
+ color: tag.color
+ };
+ },
+
+ /**
+ * Récupère un tag par son nom
+ */
+ async getTagByName(name: string): Promise {
+ const tag = await prisma.tag.findFirst({
+ where: {
+ name: {
+ equals: name
+ }
+ }
+ });
+
+ if (!tag) return null;
+
+ return {
+ id: tag.id,
+ name: tag.name,
+ color: tag.color
+ };
+ },
+
+ /**
+ * Crée un nouveau tag
+ */
+ async createTag(data: { name: string; color: string }): Promise {
+ // Vérifier si le tag existe déjà
+ const existing = await this.getTagByName(data.name);
+ if (existing) {
+ throw new Error(`Un tag avec le nom "${data.name}" existe déjà`);
+ }
+
+ const tag = await prisma.tag.create({
+ data: {
+ name: data.name.trim(),
+ color: data.color
+ }
+ });
+
+ return {
+ id: tag.id,
+ name: tag.name,
+ color: tag.color
+ };
+ },
+
+ /**
+ * Met à jour un tag
+ */
+ async updateTag(id: string, data: { name?: string; color?: string }): Promise {
+ // Vérifier que le tag existe
+ const existing = await this.getTagById(id);
+ if (!existing) {
+ throw new Error(`Tag avec l'ID "${id}" non trouvé`);
+ }
+
+ // Si on change le nom, vérifier qu'il n'existe pas déjà
+ if (data.name && data.name !== existing.name) {
+ const nameExists = await this.getTagByName(data.name);
+ if (nameExists && nameExists.id !== id) {
+ throw new Error(`Un tag avec le nom "${data.name}" existe déjà`);
+ }
+ }
+
+ const updateData: any = {};
+ if (data.name !== undefined) {
+ updateData.name = data.name.trim();
+ }
+ if (data.color !== undefined) {
+ updateData.color = data.color;
+ }
+
+ if (Object.keys(updateData).length === 0) {
+ return existing;
+ }
+
+ const tag = await prisma.tag.update({
+ where: { id },
+ data: updateData
+ });
+
+ return {
+ id: tag.id,
+ name: tag.name,
+ color: tag.color
+ };
+ },
+
+ /**
+ * Supprime un tag
+ */
+ async deleteTag(id: string): Promise {
+ // Vérifier que le tag existe
+ const existing = await this.getTagById(id);
+ if (!existing) {
+ throw new Error(`Tag avec l'ID "${id}" non trouvé`);
+ }
+
+ // Vérifier si le tag est utilisé par des tâches via la relation TaskTag
+ const taskTagCount = await prisma.taskTag.count({
+ where: { tagId: id }
+ });
+
+ if (taskTagCount > 0) {
+ throw new Error(`Impossible de supprimer le tag "${existing.name}" car il est utilisé par ${taskTagCount} tâche(s)`);
+ }
+
+ await prisma.tag.delete({
+ where: { id }
+ });
+ },
+
+ /**
+ * Récupère les tags les plus utilisés
+ */
+ async getPopularTags(limit: number = 10): Promise> {
+ // Utiliser une requête SQL brute pour compter les usages
+ const tagsWithUsage = await prisma.$queryRaw>`
+ SELECT t.id, t.name, t.color, COUNT(tt.tagId) as usage
+ FROM tags t
+ LEFT JOIN task_tags tt ON t.id = tt.tagId
+ GROUP BY t.id, t.name, t.color
+ ORDER BY usage DESC, t.name ASC
+ LIMIT ${limit}
+ `;
+
+ return tagsWithUsage.map(tag => ({
+ id: tag.id,
+ name: tag.name,
+ color: tag.color,
+ usage: Number(tag.usage)
+ }));
+ },
+
+ /**
+ * Recherche des tags par nom (pour autocomplete)
+ */
+ async searchTags(query: string, limit: number = 10): Promise {
+ const tags = await prisma.tag.findMany({
+ where: {
+ name: {
+ contains: query
+ }
+ },
+ orderBy: { name: 'asc' },
+ take: limit
+ });
+
+ return tags.map(tag => ({
+ id: tag.id,
+ name: tag.name,
+ color: tag.color
+ }));
+ },
+
+ /**
+ * Crée automatiquement des tags manquants à partir d'une liste de noms
+ */
+ async ensureTagsExist(tagNames: string[]): Promise {
+ const results: Tag[] = [];
+
+ for (const name of tagNames) {
+ if (!name.trim()) continue;
+
+ let tag = await this.getTagByName(name.trim());
+
+ if (!tag) {
+ // Générer une couleur aléatoirement
+ const colors = [
+ '#3B82F6', '#EF4444', '#10B981', '#F59E0B',
+ '#8B5CF6', '#EC4899', '#06B6D4', '#84CC16'
+ ];
+ const randomColor = colors[Math.floor(Math.random() * colors.length)];
+
+ tag = await this.createTag({
+ name: name.trim(),
+ color: randomColor
+ });
+ }
+
+ results.push(tag);
+ }
+
+ return results;
+ }
+};
\ No newline at end of file
diff --git a/services/tasks.ts b/services/tasks.ts
index 98ce7cf..e8c975c 100644
--- a/services/tasks.ts
+++ b/services/tasks.ts
@@ -31,6 +31,13 @@ export class TasksService {
const tasks = await prisma.task.findMany({
where,
+ include: {
+ taskTags: {
+ include: {
+ tag: true
+ }
+ }
+ },
take: filters?.limit || 100,
skip: filters?.offset || 0,
orderBy: [
@@ -60,19 +67,37 @@ export class TasksService {
description: taskData.description,
status: taskData.status || 'todo',
priority: taskData.priority || 'medium',
- tagsJson: JSON.stringify(taskData.tags || []),
dueDate: taskData.dueDate,
source: 'manual', // Source manuelle
sourceId: `manual-${Date.now()}` // ID unique
+ },
+ include: {
+ taskTags: {
+ include: {
+ tag: true
+ }
+ }
}
});
- // Gérer les tags
+ // Créer les relations avec les tags
if (taskData.tags && taskData.tags.length > 0) {
- await this.processTags(taskData.tags);
+ await this.createTaskTagRelations(task.id, taskData.tags);
}
- return this.mapPrismaTaskToTask(task);
+ // Récupérer la tâche avec les tags pour le retour
+ const taskWithTags = await prisma.task.findUnique({
+ where: { id: task.id },
+ include: {
+ taskTags: {
+ include: {
+ tag: true
+ }
+ }
+ }
+ });
+
+ return this.mapPrismaTaskToTask(taskWithTags!);
}
/**
@@ -104,9 +129,6 @@ export class TasksService {
updatedAt: new Date()
};
- if (updates.tags) {
- updateData.tagsJson = JSON.stringify(updates.tags);
- }
if (updates.status === 'done' && !task.completedAt) {
updateData.completedAt = new Date();
@@ -114,17 +136,29 @@ export class TasksService {
updateData.completedAt = null;
}
- const updatedTask = await prisma.task.update({
+ await prisma.task.update({
where: { id: taskId },
data: updateData
});
- // Gérer les tags
- if (updates.tags && updates.tags.length > 0) {
- await this.processTags(updates.tags);
+ // Mettre à jour les relations avec les tags
+ if (updates.tags !== undefined) {
+ await this.updateTaskTagRelations(taskId, updates.tags);
}
- return this.mapPrismaTaskToTask(updatedTask);
+ // Récupérer la tâche avec les tags pour le retour
+ const taskWithTags = await prisma.task.findUnique({
+ where: { id: taskId },
+ include: {
+ taskTags: {
+ include: {
+ tag: true
+ }
+ }
+ }
+ });
+
+ return this.mapPrismaTaskToTask(taskWithTags!);
}
/**
@@ -174,12 +208,13 @@ export class TasksService {
}
/**
- * Traite et crée les tags s'ils n'existent pas
+ * Crée les relations TaskTag pour une tâche
*/
- private async processTags(tagNames: string[]): Promise {
+ private async createTaskTagRelations(taskId: string, tagNames: string[]): Promise {
for (const tagName of tagNames) {
try {
- await prisma.tag.upsert({
+ // Créer ou récupérer le tag
+ const tag = await prisma.tag.upsert({
where: { name: tagName },
update: {}, // Pas de mise à jour nécessaire
create: {
@@ -187,12 +222,42 @@ export class TasksService {
color: this.generateTagColor(tagName)
}
});
+
+ // Créer la relation TaskTag si elle n'existe pas
+ await prisma.taskTag.upsert({
+ where: {
+ taskId_tagId: {
+ taskId: taskId,
+ tagId: tag.id
+ }
+ },
+ update: {}, // Pas de mise à jour nécessaire
+ create: {
+ taskId: taskId,
+ tagId: tag.id
+ }
+ });
} catch (error) {
- console.error(`Erreur lors de la création du tag ${tagName}:`, error);
+ console.error(`Erreur lors de la création de la relation tag ${tagName}:`, error);
}
}
}
+ /**
+ * Met à jour les relations TaskTag pour une tâche
+ */
+ private async updateTaskTagRelations(taskId: string, tagNames: string[]): Promise {
+ // Supprimer toutes les relations existantes
+ await prisma.taskTag.deleteMany({
+ where: { taskId: taskId }
+ });
+
+ // Créer les nouvelles relations
+ if (tagNames.length > 0) {
+ await this.createTaskTagRelations(taskId, tagNames);
+ }
+ }
+
/**
* Génère une couleur pour un tag basée sur son nom
*/
@@ -216,7 +281,23 @@ export class TasksService {
/**
* Convertit une tâche Prisma en objet Task
*/
- private mapPrismaTaskToTask(prismaTask: Prisma.TaskGetPayload