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

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