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:
@@ -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 */}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
201
components/forms/TagForm.tsx
Normal file
201
components/forms/TagForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user