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 ( + +
+ {/* Nom du tag */} +
+ + { + setFormData(prev => ({ ...prev, name: e.target.value })); + setErrors([]); + }} + placeholder="Nom du tag..." + maxLength={50} + disabled={loading} + className="w-full" + /> +
+ + {/* Sélecteur de couleur */} +
+ + + {/* Aperçu de la couleur sélectionnée */} +
+
+ {formData.name || 'Aperçu du tag'} +
+ + {/* Couleurs prédéfinies */} +
+ {PRESET_COLORS.map((color) => ( +
+ + {/* Couleur personnalisée */} +
+ + + { + if (TagsClient.isValidColor(e.target.value)) { + handleCustomColorChange(e as any); + } + }} + placeholder="#RRGGBB" + maxLength={7} + disabled={loading} + className="w-24 text-xs font-mono" + /> +
+
+ + {/* Erreurs */} + {errors.length > 0 && ( +
+
+ {errors.map((error, index) => ( +
• {error}
+ ))} +
+
+ )} + + {/* Actions */} +
+ + +
+ + + ); +} 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): Task { + private mapPrismaTaskToTask(prismaTask: Prisma.TaskGetPayload<{ + include: { + taskTags: { + include: { + tag: true + } + } + } + }> | Prisma.TaskGetPayload): Task { + // Extraire les tags depuis les relations TaskTag ou fallback sur tagsJson + let tags: string[] = []; + + if ('taskTags' in prismaTask && prismaTask.taskTags && Array.isArray(prismaTask.taskTags)) { + // Utiliser les relations Prisma + tags = prismaTask.taskTags.map((tt) => tt.tag.name); + } + return { id: prismaTask.id, title: prismaTask.title, @@ -224,8 +305,8 @@ export class TasksService { status: prismaTask.status as TaskStatus, priority: prismaTask.priority as TaskPriority, source: prismaTask.source as TaskSource, - sourceId: prismaTask.sourceId?? undefined, - tags: JSON.parse(prismaTask.tagsJson || '[]'), + sourceId: prismaTask.sourceId ?? undefined, + tags: tags, dueDate: prismaTask.dueDate ?? undefined, completedAt: prismaTask.completedAt ?? undefined, createdAt: prismaTask.createdAt, diff --git a/src/app/api/tags/[id]/route.ts b/src/app/api/tags/[id]/route.ts new file mode 100644 index 0000000..64d9ee8 --- /dev/null +++ b/src/app/api/tags/[id]/route.ts @@ -0,0 +1,144 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { tagsService } from '@/services/tags'; + +/** + * GET /api/tags/[id] - Récupère un tag par son ID + */ +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const tag = await tagsService.getTagById(params.id); + + if (!tag) { + return NextResponse.json( + { error: 'Tag non trouvé' }, + { status: 404 } + ); + } + + return NextResponse.json({ + data: tag, + message: 'Tag récupéré avec succès' + }); + + } catch (error) { + console.error('Erreur lors de la récupération du tag:', error); + return NextResponse.json( + { + error: 'Erreur lors de la récupération du tag', + message: error instanceof Error ? error.message : 'Erreur inconnue' + }, + { status: 500 } + ); + } +} + +/** + * PATCH /api/tags/[id] - Met à jour un tag + */ +export async function PATCH( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const body = await request.json(); + const { name, color } = body; + + // Validation + if (name !== undefined && (typeof name !== 'string' || !name.trim())) { + return NextResponse.json( + { error: 'Le nom du tag doit être une chaîne non vide' }, + { status: 400 } + ); + } + + if (color !== undefined && (typeof color !== 'string' || !/^#[0-9A-F]{6}$/i.test(color))) { + return NextResponse.json( + { error: 'La couleur doit être au format hexadécimal (#RRGGBB)' }, + { status: 400 } + ); + } + + const tag = await tagsService.updateTag(params.id, { name, color }); + + if (!tag) { + return NextResponse.json( + { error: 'Tag non trouvé' }, + { status: 404 } + ); + } + + return NextResponse.json({ + data: tag, + message: 'Tag mis à jour avec succès' + }); + + } catch (error) { + console.error('Erreur lors de la mise à jour du tag:', error); + + if (error instanceof Error && error.message.includes('non trouvé')) { + return NextResponse.json( + { error: error.message }, + { status: 404 } + ); + } + + if (error instanceof Error && error.message.includes('existe déjà')) { + return NextResponse.json( + { error: error.message }, + { status: 409 } + ); + } + + return NextResponse.json( + { + error: 'Erreur lors de la mise à jour du tag', + message: error instanceof Error ? error.message : 'Erreur inconnue' + }, + { status: 500 } + ); + } +} + +/** + * DELETE /api/tags/[id] - Supprime un tag + */ +export async function DELETE( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + await tagsService.deleteTag(params.id); + + return NextResponse.json({ + message: 'Tag supprimé avec succès' + }); + + } catch (error) { + console.error('Erreur lors de la suppression du tag:', error); + + if (error instanceof Error && error.message.includes('non trouvé')) { + return NextResponse.json( + { error: error.message }, + { status: 404 } + ); + } + + if (error instanceof Error && error.message.includes('utilisé par')) { + return NextResponse.json( + { error: error.message }, + { status: 409 } + ); + } + + return NextResponse.json( + { + error: 'Erreur lors de la suppression du tag', + message: error instanceof Error ? error.message : 'Erreur inconnue' + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/tags/route.ts b/src/app/api/tags/route.ts new file mode 100644 index 0000000..1961327 --- /dev/null +++ b/src/app/api/tags/route.ts @@ -0,0 +1,100 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { tagsService } from '@/services/tags'; + +/** + * GET /api/tags - Récupère tous les tags ou recherche par query + */ +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const query = searchParams.get('q'); + const popular = searchParams.get('popular'); + const limit = parseInt(searchParams.get('limit') || '10'); + + let tags; + + if (popular === 'true') { + // Récupérer les tags les plus utilisés + tags = await tagsService.getPopularTags(limit); + } else if (query) { + // Recherche par nom (pour autocomplete) + tags = await tagsService.searchTags(query, limit); + } else { + // Récupérer tous les tags + tags = await tagsService.getTags(); + } + + return NextResponse.json({ + data: tags, + message: 'Tags récupérés avec succès' + }); + + } catch (error) { + console.error('Erreur lors de la récupération des tags:', error); + return NextResponse.json( + { + error: 'Erreur lors de la récupération des tags', + message: error instanceof Error ? error.message : 'Erreur inconnue' + }, + { status: 500 } + ); + } +} + +/** + * POST /api/tags - Crée un nouveau tag + */ +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { name, color } = body; + + // Validation + if (!name || typeof name !== 'string') { + return NextResponse.json( + { error: 'Le nom du tag est requis' }, + { status: 400 } + ); + } + + if (!color || typeof color !== 'string') { + return NextResponse.json( + { error: 'La couleur du tag est requise' }, + { status: 400 } + ); + } + + // Validation du format couleur (hex) + if (!/^#[0-9A-F]{6}$/i.test(color)) { + return NextResponse.json( + { error: 'La couleur doit être au format hexadécimal (#RRGGBB)' }, + { status: 400 } + ); + } + + const tag = await tagsService.createTag({ name, color }); + + return NextResponse.json({ + data: tag, + message: 'Tag créé avec succès' + }, { status: 201 }); + + } catch (error) { + console.error('Erreur lors de la création du tag:', error); + + if (error instanceof Error && error.message.includes('existe déjà')) { + return NextResponse.json( + { error: error.message }, + { status: 409 } + ); + } + + return NextResponse.json( + { + error: 'Erreur lors de la création du tag', + message: error instanceof Error ? error.message : 'Erreur inconnue' + }, + { status: 500 } + ); + } +} diff --git a/src/app/tags/TagsPageClient.tsx b/src/app/tags/TagsPageClient.tsx new file mode 100644 index 0000000..86d2e2d --- /dev/null +++ b/src/app/tags/TagsPageClient.tsx @@ -0,0 +1,240 @@ +'use client'; + +import { useState } from 'react'; +import { Tag } from '@/lib/types'; +import { useTags } from '@/hooks/useTags'; +import { CreateTagData, UpdateTagData } from '@/clients/tags-client'; +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/Input'; +import { TagList } from '@/components/ui/TagList'; +import { TagForm } from '@/components/forms/TagForm'; +import Link from 'next/link'; + +interface TagsPageClientProps { + initialTags: Tag[]; +} + +export function TagsPageClient({ initialTags }: TagsPageClientProps) { + const { + tags, + popularTags, + loading, + error, + createTag, + updateTag, + deleteTag, + getPopularTags, + searchTags + } = useTags(); + + const [searchQuery, setSearchQuery] = useState(''); + const [searchResults, setSearchResults] = useState([]); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [editingTag, setEditingTag] = useState(null); + const [showPopular, setShowPopular] = useState(false); + + // Utiliser les tags initiaux si pas encore chargés + const displayTags = tags.length > 0 ? tags : initialTags; + const filteredTags = searchQuery ? searchResults : displayTags; + + const handleSearch = async (query: string) => { + setSearchQuery(query); + if (query.trim()) { + const results = await searchTags(query); + setSearchResults(results); + } else { + setSearchResults([]); + } + }; + + const handleCreateTag = async (data: CreateTagData) => { + await createTag(data); + setIsCreateModalOpen(false); + }; + + const handleEditTag = (tag: Tag) => { + setEditingTag(tag); + }; + + const handleUpdateTag = async (data: { name: string; color: string }) => { + if (editingTag) { + await updateTag({ + tagId: editingTag.id, + ...data + }); + setEditingTag(null); + } + }; + + const handleDeleteTag = async (tag: Tag) => { + if (confirm(`Êtes-vous sûr de vouloir supprimer le tag "${tag.name}" ?`)) { + try { + await deleteTag(tag.id); + } catch (error) { + // L'erreur est déjà gérée dans le hook + console.error('Erreur lors de la suppression:', error); + } + } + }; + + const handleShowPopular = async () => { + if (!showPopular) { + await getPopularTags(10); + } + setShowPopular(!showPopular); + }; + + return ( +
+ {/* Header */} +
+
+
+
+ {/* Bouton retour */} + + + + + + +
+
+

+ Gestion des Tags +

+

+ Organisez et gérez vos étiquettes +

+
+
+ +
+ + +
+
+
+
+ + {/* Contenu principal */} +
+
+ {/* Barre de recherche */} +
+ + handleSearch(e.target.value)} + placeholder="Tapez pour rechercher..." + className="max-w-md" + /> +
+ + {/* Statistiques */} +
+
+
+ {displayTags.length} +
+
+ Tags total +
+
+ +
+
+ {popularTags.length > 0 ? popularTags[0]?.usage || 0 : 0} +
+
+ Plus utilisé +
+
+ +
+
+ {searchQuery ? searchResults.length : displayTags.length} +
+
+ {searchQuery ? 'Résultats' : 'Affichés'} +
+
+
+ + {/* Messages d'état */} + {error && ( +
+
+ Erreur : {error} +
+
+ )} + + {loading && ( +
+
Chargement...
+
+ )} + + {/* Liste des tags */} +
+

+ {showPopular + ? 'Tags populaires' + : searchQuery + ? `Résultats pour "${searchQuery}"` + : 'Tous les tags' + } +

+ +
+
+
+ + {/* Modals */} + setIsCreateModalOpen(false)} + onSubmit={handleCreateTag} + loading={loading} + /> + + setEditingTag(null)} + onSubmit={handleUpdateTag} + tag={editingTag} + loading={loading} + /> +
+ ); +} diff --git a/src/app/tags/page.tsx b/src/app/tags/page.tsx new file mode 100644 index 0000000..3e57dbe --- /dev/null +++ b/src/app/tags/page.tsx @@ -0,0 +1,11 @@ +import { tagsService } from '@/services/tags'; +import { TagsPageClient } from './TagsPageClient'; + +export default async function TagsPage() { + // SSR - Récupération des tags côté serveur + const initialTags = await tagsService.getTags(); + + return ( + + ); +} diff --git a/src/contexts/TasksContext.tsx b/src/contexts/TasksContext.tsx index 711e180..090b05d 100644 --- a/src/contexts/TasksContext.tsx +++ b/src/contexts/TasksContext.tsx @@ -2,7 +2,8 @@ import { createContext, useContext, ReactNode } from 'react'; import { useTasks } from '@/hooks/useTasks'; -import { Task } from '@/lib/types'; +import { useTags } from '@/hooks/useTags'; +import { Task, Tag } from '@/lib/types'; import { CreateTaskData, UpdateTaskData, TaskFilters } from '@/clients/tasks-client'; interface TasksContextType { @@ -23,6 +24,10 @@ interface TasksContextType { deleteTask: (taskId: string) => Promise; refreshTasks: () => Promise; setFilters: (filters: TaskFilters) => void; + // Tags + tags: Tag[]; + tagsLoading: boolean; + tagsError: string | null; } const TasksContext = createContext(null); @@ -39,8 +44,17 @@ export function TasksProvider({ children, initialTasks, initialStats }: TasksPro { tasks: initialTasks, stats: initialStats } ); + const { tags, loading: tagsLoading, error: tagsError } = useTags(); + + const contextValue: TasksContextType = { + ...tasksState, + tags, + tagsLoading, + tagsError + }; + return ( - + {children} );