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.
This commit is contained in:
Julien Froidefond
2025-09-14 16:44:22 +02:00
parent edbd82e8ac
commit c5a7d16425
27 changed files with 2055 additions and 224 deletions

23
TODO.md
View File

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

166
clients/tags-client.ts Normal file
View File

@@ -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<TagsResponse> {
const params: Record<string, string> = {};
if (filters?.q) {
params.q = filters.q;
}
if (filters?.popular) {
params.popular = 'true';
}
if (filters?.limit) {
params.limit = filters.limit.toString();
}
return this.get<TagsResponse>('', Object.keys(params).length > 0 ? params : undefined);
}
/**
* Récupère les tags populaires (les plus utilisés)
*/
async getPopularTags(limit: number = 10): Promise<PopularTagsResponse> {
return this.get<PopularTagsResponse>('', { popular: 'true', limit: limit.toString() });
}
/**
* Recherche des tags par nom (pour autocomplete)
*/
async searchTags(query: string, limit: number = 10): Promise<TagsResponse> {
return this.get<TagsResponse>('', { q: query, limit: limit.toString() });
}
/**
* Récupère un tag par son ID
*/
async getTagById(id: string): Promise<TagResponse> {
return this.get<TagResponse>(`/${id}`);
}
/**
* Crée un nouveau tag
*/
async createTag(data: CreateTagData): Promise<TagResponse> {
return this.post<TagResponse>('', data);
}
/**
* Met à jour un tag
*/
async updateTag(data: UpdateTagData): Promise<TagResponse> {
const { tagId, ...updates } = data;
return this.patch<TagResponse>(`/${tagId}`, updates);
}
/**
* Supprime un tag
*/
async deleteTag(id: string): Promise<ApiResponse<void>> {
return this.delete<ApiResponse<void>>(`/${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<CreateTagData>): 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();

View File

@@ -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<Record<string, string>>({});
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 (
<Modal isOpen={isOpen} onClose={handleClose} title="Nouvelle tâche" size="lg">
@@ -188,39 +162,12 @@ export function CreateTaskForm({ isOpen, onClose, onSubmit, loading = false }: C
Tags
</label>
<div className="flex gap-2">
<Input
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyPress={handleTagKeyPress}
placeholder="Ajouter un tag..."
disabled={loading}
className="flex-1"
/>
<Button
type="button"
variant="secondary"
onClick={addTag}
disabled={!tagInput.trim() || loading}
>
Ajouter
</Button>
</div>
{formData.tags && formData.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{formData.tags.map((tag: string, index: number) => (
<Badge
key={index}
variant="primary"
className="cursor-pointer hover:bg-red-950/50 hover:text-red-300 hover:border-red-500/30 transition-colors"
onClick={() => removeTag(tag)}
>
{tag}
</Badge>
))}
</div>
)}
<TagInput
tags={formData.tags || []}
onChange={(tags) => setFormData(prev => ({ ...prev, tags }))}
placeholder="Ajouter des tags..."
maxTags={10}
/>
</div>
{/* Actions */}

View File

@@ -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<Record<string, string>>({});
// 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
</label>
<div className="flex gap-2">
<Input
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyPress={handleTagKeyPress}
placeholder="Ajouter un tag..."
disabled={loading}
className="flex-1"
/>
<Button
type="button"
variant="secondary"
onClick={addTag}
disabled={!tagInput.trim() || loading}
>
Ajouter
</Button>
</div>
{formData.tags && formData.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{formData.tags.map((tag: string, index: number) => (
<Badge
key={index}
variant="primary"
className="cursor-pointer hover:bg-red-950/50 hover:text-red-300 hover:border-red-500/30 transition-colors"
onClick={() => removeTag(tag)}
>
{tag}
</Badge>
))}
</div>
)}
<TagInput
tags={formData.tags || []}
onChange={(tags) => setFormData(prev => ({ ...prev, tags }))}
placeholder="Ajouter des tags..."
maxTags={10}
/>
</div>
{/* Actions */}

View File

@@ -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<void>;
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<string[]>([]);
// 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<HTMLInputElement>) => {
setFormData(prev => ({ ...prev, color: e.target.value }));
setErrors([]);
};
return (
<Modal isOpen={isOpen} onClose={onClose} title={tag ? 'Éditer le tag' : 'Nouveau tag'}>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Nom du tag */}
<div>
<label htmlFor="tag-name" className="block text-sm font-medium text-slate-200 mb-2">
Nom du tag
</label>
<Input
id="tag-name"
type="text"
value={formData.name}
onChange={(e) => {
setFormData(prev => ({ ...prev, name: e.target.value }));
setErrors([]);
}}
placeholder="Nom du tag..."
maxLength={50}
disabled={loading}
className="w-full"
/>
</div>
{/* Sélecteur de couleur */}
<div>
<label className="block text-sm font-medium text-slate-200 mb-3">
Couleur du tag
</label>
{/* Aperçu de la couleur sélectionnée */}
<div className="flex items-center gap-3 mb-4 p-3 bg-slate-800 rounded-lg border border-slate-600">
<div
className="w-6 h-6 rounded-full border-2 border-slate-500"
style={{ backgroundColor: formData.color }}
/>
<span className="text-slate-200 font-medium">{formData.name || 'Aperçu du tag'}</span>
</div>
{/* Couleurs prédéfinies */}
<div className="grid grid-cols-6 gap-2 mb-4">
{PRESET_COLORS.map((color) => (
<button
key={color}
type="button"
onClick={() => handleColorSelect(color)}
className={`w-10 h-10 rounded-lg border-2 transition-all hover:scale-110 ${
formData.color === color
? 'border-white shadow-lg'
: 'border-slate-600 hover:border-slate-400'
}`}
style={{ backgroundColor: color }}
title={color}
/>
))}
</div>
{/* Couleur personnalisée */}
<div className="flex items-center gap-3">
<label htmlFor="custom-color" className="text-sm text-slate-400">
Couleur personnalisée :
</label>
<input
id="custom-color"
type="color"
value={formData.color}
onChange={handleCustomColorChange}
disabled={loading}
className="w-12 h-8 rounded border border-slate-600 bg-slate-800 cursor-pointer disabled:cursor-not-allowed"
/>
<Input
type="text"
value={formData.color}
onChange={(e) => {
if (TagsClient.isValidColor(e.target.value)) {
handleCustomColorChange(e as any);
}
}}
placeholder="#RRGGBB"
maxLength={7}
disabled={loading}
className="w-24 text-xs font-mono"
/>
</div>
</div>
{/* Erreurs */}
{errors.length > 0 && (
<div className="bg-red-900/20 border border-red-500/30 rounded-lg p-3">
<div className="text-red-400 text-sm space-y-1">
{errors.map((error, index) => (
<div key={index}> {error}</div>
))}
</div>
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-3 pt-4 border-t border-slate-700">
<Button
type="button"
variant="secondary"
onClick={onClose}
disabled={loading}
>
Annuler
</Button>
<Button
type="submit"
variant="primary"
disabled={loading || !formData.name.trim()}
>
{loading ? 'Enregistrement...' : (tag ? 'Mettre à jour' : 'Créer')}
</Button>
</div>
</form>
</Modal>
);
}

View File

@@ -118,6 +118,7 @@ export function KanbanBoard({ tasks, onCreateTask, onDeleteTask, onEditTask, onU
return (
<DndContext
id="kanban-board"
sensors={sensors}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}

View File

@@ -2,7 +2,7 @@
import { useState, useRef, useEffect } from 'react';
import { Card } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { TagInput } from '@/components/ui/TagInput';
import { TaskStatus, TaskPriority } from '@/lib/types';
import { CreateTaskData } from '@/clients/tasks-client';
@@ -21,7 +21,6 @@ export function QuickAddTask({ status, onSubmit, onCancel }: QuickAddTaskProps)
tags: [],
dueDate: undefined
});
const [tagInput, setTagInput] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [activeField, setActiveField] = useState<'title' | 'description' | 'tags' | 'date' | null>('title');
const titleRef = useRef<HTMLInputElement>(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 */}
<div className="mb-2">
<div className="flex flex-wrap gap-1 mb-1">
{formData.tags && formData.tags.map((tag: string, index: number) => (
<Badge
key={index}
variant="primary"
size="sm"
className="cursor-pointer hover:bg-red-950/50 hover:text-red-300 hover:border-red-500/30 transition-colors"
onClick={() => removeTag(tag)}
>
{tag}
</Badge>
))}
</div>
<input
type="text"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) => handleKeyDown(e, 'tags')}
onFocus={() => setActiveField('tags')}
<TagInput
tags={formData.tags || []}
onChange={handleTagsChange}
placeholder="Tags..."
disabled={isSubmitting}
className="w-full bg-transparent border-none outline-none text-xs text-slate-400 font-mono placeholder-slate-500"
maxTags={5}
className="text-xs"
/>
</div>

View File

@@ -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
</p>
)}
{/* Tags avec composant Badge */}
{/* Tags avec couleurs */}
{task.tags && task.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mb-3">
{task.tags.slice(0, 3).map((tag, index) => (
<Badge
key={index}
variant="primary"
size="sm"
className="hover:bg-cyan-950/80 transition-colors"
>
{tag}
</Badge>
))}
{task.tags.length > 3 && (
<Badge variant="outline" size="sm">
+{task.tags.length - 3}
</Badge>
)}
<div className="mb-3">
<TagDisplay
tags={task.tags}
availableTags={availableTags}
size="sm"
maxTags={3}
showColors={true}
/>
</div>
)}

View File

@@ -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)
<div className="container mx-auto px-6 py-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-6">
{/* Titre tech avec glow */}
<div className="flex items-center gap-4">
<div className={`w-3 h-3 rounded-full shadow-lg ${
syncing
? 'bg-yellow-400 animate-spin shadow-yellow-400/50'
: 'bg-cyan-400 animate-pulse shadow-cyan-400/50'
}`}></div>
<div>
<h1 className="text-2xl font-mono font-bold text-slate-100 tracking-wider">
{title}
</h1>
<p className="text-slate-400 mt-1 font-mono text-sm">
{subtitle} {syncing && '• Synchronisation...'}
</p>
<div className="flex items-center gap-6">
<div className="flex items-center gap-4">
<div className={`w-3 h-3 rounded-full shadow-lg ${
syncing
? 'bg-yellow-400 animate-spin shadow-yellow-400/50'
: 'bg-cyan-400 animate-pulse shadow-cyan-400/50'
}`}></div>
<div>
<h1 className="text-2xl font-mono font-bold text-slate-100 tracking-wider">
{title}
</h1>
<p className="text-slate-400 mt-1 font-mono text-sm">
{subtitle} {syncing && '• Synchronisation...'}
</p>
</div>
</div>
{/* Navigation */}
<nav className="hidden sm:flex items-center gap-4">
<Link
href="/"
className="text-slate-400 hover:text-cyan-400 transition-colors font-mono text-sm uppercase tracking-wider"
>
Kanban
</Link>
<Link
href="/tags"
className="text-slate-400 hover:text-purple-400 transition-colors font-mono text-sm uppercase tracking-wider"
>
Tags
</Link>
</nav>
</div>
{/* Stats tech dashboard */}

View File

@@ -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 (
<div className={`flex flex-wrap gap-1 ${className}`}>
{displayTags.map((tagName, index) => {
const color = getTagColor(tagName);
return (
<div
key={index}
onClick={() => 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 && (
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: color }}
/>
)}
<span className="font-medium">{tagName}</span>
</div>
);
})}
{remainingCount > 0 && (
<Badge variant="default" size="sm">
+{remainingCount}
</Badge>
)}
</div>
);
}
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 (
<div className="text-center py-8 text-slate-400">
<div className="text-4xl mb-2">🏷</div>
<p className="text-sm">Aucun tag trouvé</p>
</div>
);
}
return (
<div className={`space-y-2 ${className}`}>
{tags.map((tag) => (
<div
key={tag.id}
className="flex items-center justify-between p-3 bg-slate-800 rounded-lg border border-slate-700 hover:border-slate-600 transition-colors group"
>
<div
className="flex items-center gap-3 flex-1 cursor-pointer"
onClick={() => onTagClick?.(tag)}
>
<div
className="w-4 h-4 rounded-full"
style={{ backgroundColor: tag.color }}
/>
<span className="text-slate-200 font-medium">{tag.name}</span>
</div>
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
{onTagEdit && (
<button
onClick={() => onTagEdit(tag)}
className="p-1 text-slate-400 hover:text-cyan-400 transition-colors"
title="Éditer le tag"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
)}
{onTagDelete && (
<button
onClick={() => onTagDelete(tag)}
className="p-1 text-slate-400 hover:text-red-400 transition-colors"
title="Supprimer le tag"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
)}
</div>
</div>
))}
</div>
);
}

184
components/ui/TagInput.tsx Normal file
View File

@@ -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<HTMLInputElement>(null);
const suggestionsRef = useRef<HTMLDivElement>(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 (
<div className={`relative ${className}`}>
{/* Container des tags et input */}
<div className="min-h-[42px] p-2 border border-slate-600 rounded-lg bg-slate-800 focus-within:border-cyan-400 focus-within:ring-1 focus-within:ring-cyan-400/20 transition-colors">
<div className="flex flex-wrap gap-1 items-center">
{/* Tags existants */}
{tags.map((tag, index) => (
<Badge
key={index}
variant="default"
className="flex items-center gap-1 px-2 py-1 text-xs"
>
<span>{tag}</span>
<button
type="button"
onClick={() => removeTag(tag)}
className="text-slate-400 hover:text-slate-200 ml-1"
aria-label={`Supprimer le tag ${tag}`}
>
×
</button>
</Badge>
))}
{/* Input pour nouveau tag */}
{tags.length < maxTags && (
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={(e) => 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"
/>
)}
</div>
</div>
{/* Suggestions dropdown */}
{showSuggestions && (suggestions.length > 0 || loading) && (
<div
ref={suggestionsRef}
className="absolute top-full left-0 right-0 mt-1 bg-slate-800 border border-slate-600 rounded-lg shadow-lg z-50 max-h-48 overflow-y-auto"
>
{loading ? (
<div className="p-3 text-center text-slate-400 text-sm">
Recherche...
</div>
) : (
suggestions.map((tag, index) => (
<button
key={tag.id}
type="button"
onClick={() => handleSuggestionClick(tag)}
className={`w-full text-left px-3 py-2 text-sm transition-colors ${
index === selectedIndex
? 'bg-slate-700 text-cyan-300'
: 'text-slate-200 hover:bg-slate-700'
} ${tags.includes(tag.name) ? 'opacity-50 cursor-not-allowed' : ''}`}
disabled={tags.includes(tag.name)}
>
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: tag.color }}
/>
<span>{tag.name}</span>
{tags.includes(tag.name) && (
<span className="text-xs text-slate-400 ml-auto">Déjà ajouté</span>
)}
</div>
</button>
))
)}
</div>
)}
{/* Indicateur de limite */}
{tags.length >= maxTags && (
<div className="text-xs text-slate-400 mt-1">
Limite de {maxTags} tags atteinte
</div>
)}
</div>
);
}

91
components/ui/TagList.tsx Normal file
View File

@@ -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 (
<div className="text-center py-12 text-slate-400">
<div className="text-6xl mb-4">🏷</div>
<p className="text-lg mb-2">Aucun tag trouvé</p>
<p className="text-sm">Créez votre premier tag pour commencer</p>
</div>
);
}
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{tags.map((tag) => (
<div
key={tag.id}
className="group relative bg-slate-800/50 rounded-lg border border-slate-700 hover:border-slate-600 transition-all duration-200 hover:shadow-lg hover:shadow-slate-900/20 p-3"
>
{/* Contenu principal */}
<div className="flex items-center gap-3">
<div
className="w-5 h-5 rounded-full shadow-sm"
style={{ backgroundColor: tag.color }}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<h3 className="text-slate-200 font-medium truncate">
{tag.name}
</h3>
{tag.usage !== undefined && (
<span className="text-xs text-slate-400 bg-slate-700/50 px-2 py-1 rounded-full ml-2 flex-shrink-0">
{tag.usage}
</span>
)}
</div>
</div>
</div>
{/* Actions (apparaissent au hover) */}
{showActions && (onTagEdit || onTagDelete) && (
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{onTagEdit && (
<Button
variant="ghost"
size="sm"
onClick={() => onTagEdit(tag)}
className="h-7 px-2 text-xs bg-slate-700/80 backdrop-blur-sm border border-slate-600 hover:border-slate-500 hover:bg-slate-600"
>
</Button>
)}
{onTagDelete && (
<Button
variant="ghost"
size="sm"
onClick={() => onTagDelete(tag)}
className="h-7 px-2 text-xs bg-slate-700/80 backdrop-blur-sm border border-slate-600 hover:border-red-500 hover:text-red-400 hover:bg-red-900/20"
>
🗑
</Button>
)}
</div>
)}
{/* Indicateur de couleur en bas */}
<div
className="absolute bottom-0 left-0 right-0 h-1 rounded-b-lg opacity-30"
style={{ backgroundColor: tag.color }}
/>
</div>
))}
</div>
);
}

223
hooks/useTags.ts Normal file
View File

@@ -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<Tag & { usage: number }>;
loading: boolean;
error: string | null;
}
interface UseTagsActions {
refreshTags: () => Promise<void>;
searchTags: (query: string, limit?: number) => Promise<Tag[]>;
createTag: (data: CreateTagData) => Promise<Tag | null>;
updateTag: (data: UpdateTagData) => Promise<Tag | null>;
deleteTag: (tagId: string) => Promise<void>;
getPopularTags: (limit?: number) => Promise<void>;
setFilters: (filters: TagFilters) => void;
}
/**
* Hook pour la gestion des tags
*/
export function useTags(
initialFilters?: TagFilters
): UseTagsState & UseTagsActions {
const [state, setState] = useState<UseTagsState>({
tags: [],
popularTags: [],
loading: false,
error: null
});
const [filters, setFilters] = useState<TagFilters>(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<Tag[]> => {
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<Tag | null> => {
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<Tag | null> => {
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<void> => {
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<Tag[]>([]);
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
};
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -52,7 +52,8 @@ async function seedTestData() {
const priorityEmoji = {
'low': '🔵',
'medium': '🟡',
'high': '🔴'
'high': '🔴',
'urgent': '🚨'
}[task.priority];
console.log(` ${statusEmoji} ${priorityEmoji} ${task.title}`);

35
scripts/seed-tags.ts Normal file
View File

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

237
services/tags.ts Normal file
View File

@@ -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<Tag | null> {
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<Tag | null> {
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<Tag> {
// 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<Tag | null> {
// 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<void> {
// 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<Array<Tag & { usage: number }>> {
// Utiliser une requête SQL brute pour compter les usages
const tagsWithUsage = await prisma.$queryRaw<Array<{
id: string;
name: string;
color: string;
usage: number;
}>>`
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<Tag[]> {
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<Tag[]> {
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;
}
};

View File

@@ -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<void> {
private async createTaskTagRelations(taskId: string, tagNames: string[]): Promise<void> {
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<void> {
// 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<object>): Task {
private mapPrismaTaskToTask(prismaTask: Prisma.TaskGetPayload<{
include: {
taskTags: {
include: {
tag: true
}
}
}
}> | Prisma.TaskGetPayload<object>): 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,

View File

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

100
src/app/api/tags/route.ts Normal file
View File

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

View File

@@ -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<Tag[]>([]);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [editingTag, setEditingTag] = useState<Tag | null>(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 (
<div className="min-h-screen bg-slate-950">
{/* Header */}
<div className="bg-slate-900/80 backdrop-blur-sm border-b border-slate-700/50">
<div className="container mx-auto px-6 py-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex items-center gap-4">
{/* Bouton retour */}
<Link
href="/"
className="flex items-center justify-center w-10 h-10 rounded-lg bg-slate-800/50 border border-slate-700 hover:border-cyan-400/50 hover:bg-slate-700/50 transition-all duration-200 group"
title="Retour au Kanban"
>
<svg
className="w-5 h-5 text-slate-400 group-hover:text-cyan-400 transition-colors"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</Link>
<div className="w-3 h-3 bg-purple-400 rounded-full animate-pulse shadow-purple-400/50 shadow-lg"></div>
<div>
<h1 className="text-2xl font-mono font-bold text-slate-100 tracking-wider">
Gestion des Tags
</h1>
<p className="text-slate-400 mt-1 font-mono text-sm">
Organisez et gérez vos étiquettes
</p>
</div>
</div>
<div className="flex items-center gap-3">
<Button
variant="secondary"
onClick={handleShowPopular}
disabled={loading}
>
{showPopular ? 'Tous les tags' : 'Tags populaires'}
</Button>
<Button
variant="primary"
onClick={() => setIsCreateModalOpen(true)}
disabled={loading}
>
+ Nouveau tag
</Button>
</div>
</div>
</div>
</div>
{/* Contenu principal */}
<div className="container mx-auto px-6 py-8">
<div className="max-w-4xl mx-auto space-y-8">
{/* Barre de recherche */}
<div className="space-y-2">
<label className="block text-sm font-mono font-medium text-slate-300 uppercase tracking-wider">
Rechercher des tags
</label>
<Input
type="text"
value={searchQuery}
onChange={(e) => handleSearch(e.target.value)}
placeholder="Tapez pour rechercher..."
className="max-w-md"
/>
</div>
{/* Statistiques */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="bg-slate-800/50 rounded-lg p-4 border border-slate-700">
<div className="text-2xl font-mono font-bold text-slate-100">
{displayTags.length}
</div>
<div className="text-sm text-slate-400 font-mono uppercase tracking-wide">
Tags total
</div>
</div>
<div className="bg-slate-800/50 rounded-lg p-4 border border-slate-700">
<div className="text-2xl font-mono font-bold text-slate-100">
{popularTags.length > 0 ? popularTags[0]?.usage || 0 : 0}
</div>
<div className="text-sm text-slate-400 font-mono uppercase tracking-wide">
Plus utilisé
</div>
</div>
<div className="bg-slate-800/50 rounded-lg p-4 border border-slate-700">
<div className="text-2xl font-mono font-bold text-slate-100">
{searchQuery ? searchResults.length : displayTags.length}
</div>
<div className="text-sm text-slate-400 font-mono uppercase tracking-wide">
{searchQuery ? 'Résultats' : 'Affichés'}
</div>
</div>
</div>
{/* Messages d'état */}
{error && (
<div className="bg-red-900/20 border border-red-500/30 rounded-lg p-4">
<div className="text-red-400 text-sm">
Erreur : {error}
</div>
</div>
)}
{loading && (
<div className="text-center py-8">
<div className="text-slate-400">Chargement...</div>
</div>
)}
{/* Liste des tags */}
<div className="space-y-4">
<h2 className="text-lg font-mono font-bold text-slate-200 uppercase tracking-wider">
{showPopular
? 'Tags populaires'
: searchQuery
? `Résultats pour "${searchQuery}"`
: 'Tous les tags'
}
</h2>
<TagList
tags={showPopular ? popularTags : filteredTags}
onTagEdit={handleEditTag}
onTagDelete={handleDeleteTag}
showUsage={true}
/>
</div>
</div>
</div>
{/* Modals */}
<TagForm
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
onSubmit={handleCreateTag}
loading={loading}
/>
<TagForm
isOpen={!!editingTag}
onClose={() => setEditingTag(null)}
onSubmit={handleUpdateTag}
tag={editingTag}
loading={loading}
/>
</div>
);
}

11
src/app/tags/page.tsx Normal file
View File

@@ -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 (
<TagsPageClient initialTags={initialTags} />
);
}

View File

@@ -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<void>;
refreshTasks: () => Promise<void>;
setFilters: (filters: TaskFilters) => void;
// Tags
tags: Tag[];
tagsLoading: boolean;
tagsError: string | null;
}
const TasksContext = createContext<TasksContextType | null>(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 (
<TasksContext.Provider value={tasksState}>
<TasksContext.Provider value={contextValue}>
{children}
</TasksContext.Provider>
);