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:
23
TODO.md
23
TODO.md
@@ -47,18 +47,27 @@
|
|||||||
- [x] Validation des formulaires et gestion d'erreurs
|
- [x] Validation des formulaires et gestion d'erreurs
|
||||||
|
|
||||||
### 2.4 Gestion des tags
|
### 2.4 Gestion des tags
|
||||||
- [ ] Créer/éditer des tags avec sélecteur de couleur
|
- [x] Créer/éditer des tags avec sélecteur de couleur
|
||||||
- [ ] Autocomplete pour les tags existants
|
- [x] Autocomplete pour les tags existants
|
||||||
- [ ] Suppression de tags (avec vérification des dépendances)
|
- [x] Suppression de tags (avec vérification des dépendances)
|
||||||
- [ ] Affichage des tags avec couleurs personnalisées
|
- [x] Affichage des tags avec couleurs personnalisées
|
||||||
- [ ] Filtrage par tags
|
- [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
|
### 2.5 Clients HTTP et hooks
|
||||||
- [x] `clients/tasks-client.ts` - Client pour les tâches (CRUD complet)
|
- [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] `clients/base/http-client.ts` - Client HTTP de base
|
||||||
- [x] `hooks/useTasks.ts` - Hook pour la gestion des tâches (CRUD complet)
|
- [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
|
- [ ] `hooks/useKanban.ts` - Hook pour drag & drop
|
||||||
- [x] Gestion des erreurs et loading states
|
- [x] Gestion des erreurs et loading states
|
||||||
- [x] Architecture SSR + hydratation client optimisée
|
- [x] Architecture SSR + hydratation client optimisée
|
||||||
|
|||||||
166
clients/tags-client.ts
Normal file
166
clients/tags-client.ts
Normal 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();
|
||||||
@@ -4,7 +4,7 @@ import { useState } from 'react';
|
|||||||
import { Modal } from '@/components/ui/Modal';
|
import { Modal } from '@/components/ui/Modal';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input } from '@/components/ui/Input';
|
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 { TaskPriority, TaskStatus } from '@/lib/types';
|
||||||
import { CreateTaskData } from '@/clients/tasks-client';
|
import { CreateTaskData } from '@/clients/tasks-client';
|
||||||
|
|
||||||
@@ -25,7 +25,6 @@ export function CreateTaskForm({ isOpen, onClose, onSubmit, loading = false }: C
|
|||||||
dueDate: undefined
|
dueDate: undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
const [tagInput, setTagInput] = useState('');
|
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
const validateForm = (): boolean => {
|
const validateForm = (): boolean => {
|
||||||
@@ -69,35 +68,10 @@ export function CreateTaskForm({ isOpen, onClose, onSubmit, loading = false }: C
|
|||||||
tags: [],
|
tags: [],
|
||||||
dueDate: undefined
|
dueDate: undefined
|
||||||
});
|
});
|
||||||
setTagInput('');
|
|
||||||
setErrors({});
|
setErrors({});
|
||||||
onClose();
|
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 (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={handleClose} title="Nouvelle tâche" size="lg">
|
<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
|
Tags
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<TagInput
|
||||||
<Input
|
tags={formData.tags || []}
|
||||||
value={tagInput}
|
onChange={(tags) => setFormData(prev => ({ ...prev, tags }))}
|
||||||
onChange={(e) => setTagInput(e.target.value)}
|
placeholder="Ajouter des tags..."
|
||||||
onKeyPress={handleTagKeyPress}
|
maxTags={10}
|
||||||
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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import { Modal } from '@/components/ui/Modal';
|
import { Modal } from '@/components/ui/Modal';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input } from '@/components/ui/Input';
|
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 { Task, TaskPriority, TaskStatus } from '@/lib/types';
|
||||||
import { UpdateTaskData } from '@/clients/tasks-client';
|
import { UpdateTaskData } from '@/clients/tasks-client';
|
||||||
|
|
||||||
@@ -26,7 +26,6 @@ export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false
|
|||||||
dueDate: undefined
|
dueDate: undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
const [tagInput, setTagInput] = useState('');
|
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
// Pré-remplir le formulaire quand la tâche change
|
// Pré-remplir le formulaire quand la tâche change
|
||||||
@@ -79,35 +78,10 @@ export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
setTagInput('');
|
|
||||||
setErrors({});
|
setErrors({});
|
||||||
onClose();
|
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;
|
if (!task) return null;
|
||||||
|
|
||||||
@@ -200,39 +174,12 @@ export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false
|
|||||||
Tags
|
Tags
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<TagInput
|
||||||
<Input
|
tags={formData.tags || []}
|
||||||
value={tagInput}
|
onChange={(tags) => setFormData(prev => ({ ...prev, tags }))}
|
||||||
onChange={(e) => setTagInput(e.target.value)}
|
placeholder="Ajouter des tags..."
|
||||||
onKeyPress={handleTagKeyPress}
|
maxTags={10}
|
||||||
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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -118,6 +118,7 @@ export function KanbanBoard({ tasks, onCreateTask, onDeleteTask, onEditTask, onU
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DndContext
|
<DndContext
|
||||||
|
id="kanban-board"
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { Card } from '@/components/ui/Card';
|
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 { TaskStatus, TaskPriority } from '@/lib/types';
|
||||||
import { CreateTaskData } from '@/clients/tasks-client';
|
import { CreateTaskData } from '@/clients/tasks-client';
|
||||||
|
|
||||||
@@ -21,7 +21,6 @@ export function QuickAddTask({ status, onSubmit, onCancel }: QuickAddTaskProps)
|
|||||||
tags: [],
|
tags: [],
|
||||||
dueDate: undefined
|
dueDate: undefined
|
||||||
});
|
});
|
||||||
const [tagInput, setTagInput] = useState('');
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [activeField, setActiveField] = useState<'title' | 'description' | 'tags' | 'date' | null>('title');
|
const [activeField, setActiveField] = useState<'title' | 'description' | 'tags' | 'date' | null>('title');
|
||||||
const titleRef = useRef<HTMLInputElement>(null);
|
const titleRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -53,7 +52,6 @@ export function QuickAddTask({ status, onSubmit, onCancel }: QuickAddTaskProps)
|
|||||||
tags: [],
|
tags: [],
|
||||||
dueDate: undefined
|
dueDate: undefined
|
||||||
});
|
});
|
||||||
setTagInput('');
|
|
||||||
setActiveField('title');
|
setActiveField('title');
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
titleRef.current?.focus();
|
titleRef.current?.focus();
|
||||||
@@ -73,9 +71,8 @@ export function QuickAddTask({ status, onSubmit, onCancel }: QuickAddTaskProps)
|
|||||||
if (field === 'title' && formData.title.trim()) {
|
if (field === 'title' && formData.title.trim()) {
|
||||||
console.log('Calling handleSubmit from title');
|
console.log('Calling handleSubmit from title');
|
||||||
handleSubmit();
|
handleSubmit();
|
||||||
} else if (field === 'tags' && tagInput.trim()) {
|
} else if (field === 'tags') {
|
||||||
console.log('Adding tag');
|
// TagInput gère ses propres événements Enter
|
||||||
addTag();
|
|
||||||
} else if (formData.title.trim()) {
|
} else if (formData.title.trim()) {
|
||||||
// Permettre création depuis n'importe quel champ si titre rempli
|
// Permettre création depuis n'importe quel champ si titre rempli
|
||||||
console.log('Calling handleSubmit from other field');
|
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)
|
// Laisser passer tous les autres événements (y compris les raccourcis système)
|
||||||
};
|
};
|
||||||
|
|
||||||
const addTag = () => {
|
const handleTagsChange = (tags: string[]) => {
|
||||||
const tag = tagInput.trim();
|
|
||||||
if (tag && !formData.tags?.includes(tag)) {
|
|
||||||
setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
tags: [...(prev.tags || []), tag]
|
|
||||||
}));
|
|
||||||
setTagInput('');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeTag = (tagToRemove: string) => {
|
|
||||||
setFormData(prev => ({
|
setFormData(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
tags: prev.tags?.filter(tag => tag !== tagToRemove) || []
|
tags
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -171,28 +157,12 @@ export function QuickAddTask({ status, onSubmit, onCancel }: QuickAddTaskProps)
|
|||||||
|
|
||||||
{/* Tags */}
|
{/* Tags */}
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
<div className="flex flex-wrap gap-1 mb-1">
|
<TagInput
|
||||||
{formData.tags && formData.tags.map((tag: string, index: number) => (
|
tags={formData.tags || []}
|
||||||
<Badge
|
onChange={handleTagsChange}
|
||||||
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')}
|
|
||||||
placeholder="Tags..."
|
placeholder="Tags..."
|
||||||
disabled={isSubmitting}
|
maxTags={5}
|
||||||
className="w-full bg-transparent border-none outline-none text-xs text-slate-400 font-mono placeholder-slate-500"
|
className="text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { formatDistanceToNow } from 'date-fns';
|
|||||||
import { fr } from 'date-fns/locale';
|
import { fr } from 'date-fns/locale';
|
||||||
import { Card } from '@/components/ui/Card';
|
import { Card } from '@/components/ui/Card';
|
||||||
import { Badge } from '@/components/ui/Badge';
|
import { Badge } from '@/components/ui/Badge';
|
||||||
|
import { TagDisplay } from '@/components/ui/TagDisplay';
|
||||||
|
import { useTasksContext } from '@/contexts/TasksContext';
|
||||||
import { useDraggable } from '@dnd-kit/core';
|
import { useDraggable } from '@dnd-kit/core';
|
||||||
|
|
||||||
interface TaskCardProps {
|
interface TaskCardProps {
|
||||||
@@ -16,6 +18,7 @@ interface TaskCardProps {
|
|||||||
export function TaskCard({ task, onDelete, onEdit, onUpdateTitle }: TaskCardProps) {
|
export function TaskCard({ task, onDelete, onEdit, onUpdateTitle }: TaskCardProps) {
|
||||||
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||||||
const [editTitle, setEditTitle] = useState(task.title);
|
const [editTitle, setEditTitle] = useState(task.title);
|
||||||
|
const { tags: availableTags } = useTasksContext();
|
||||||
|
|
||||||
// Configuration du draggable
|
// Configuration du draggable
|
||||||
const {
|
const {
|
||||||
@@ -170,24 +173,16 @@ export function TaskCard({ task, onDelete, onEdit, onUpdateTitle }: TaskCardProp
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tags avec composant Badge */}
|
{/* Tags avec couleurs */}
|
||||||
{task.tags && task.tags.length > 0 && (
|
{task.tags && task.tags.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1 mb-3">
|
<div className="mb-3">
|
||||||
{task.tags.slice(0, 3).map((tag, index) => (
|
<TagDisplay
|
||||||
<Badge
|
tags={task.tags}
|
||||||
key={index}
|
availableTags={availableTags}
|
||||||
variant="primary"
|
size="sm"
|
||||||
size="sm"
|
maxTags={3}
|
||||||
className="hover:bg-cyan-950/80 transition-colors"
|
showColors={true}
|
||||||
>
|
/>
|
||||||
{tag}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
{task.tags.length > 3 && (
|
|
||||||
<Badge variant="outline" size="sm">
|
|
||||||
+{task.tags.length - 3}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Card, CardContent } from '@/components/ui/Card';
|
import { Card, CardContent } from '@/components/ui/Card';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
title: string;
|
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="container mx-auto px-6 py-4">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-6">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-6">
|
||||||
{/* Titre tech avec glow */}
|
{/* Titre tech avec glow */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-6">
|
||||||
<div className={`w-3 h-3 rounded-full shadow-lg ${
|
<div className="flex items-center gap-4">
|
||||||
syncing
|
<div className={`w-3 h-3 rounded-full shadow-lg ${
|
||||||
? 'bg-yellow-400 animate-spin shadow-yellow-400/50'
|
syncing
|
||||||
: 'bg-cyan-400 animate-pulse shadow-cyan-400/50'
|
? 'bg-yellow-400 animate-spin shadow-yellow-400/50'
|
||||||
}`}></div>
|
: 'bg-cyan-400 animate-pulse shadow-cyan-400/50'
|
||||||
<div>
|
}`}></div>
|
||||||
<h1 className="text-2xl font-mono font-bold text-slate-100 tracking-wider">
|
<div>
|
||||||
{title}
|
<h1 className="text-2xl font-mono font-bold text-slate-100 tracking-wider">
|
||||||
</h1>
|
{title}
|
||||||
<p className="text-slate-400 mt-1 font-mono text-sm">
|
</h1>
|
||||||
{subtitle} {syncing && '• Synchronisation...'}
|
<p className="text-slate-400 mt-1 font-mono text-sm">
|
||||||
</p>
|
{subtitle} {syncing && '• Synchronisation...'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* Stats tech dashboard */}
|
{/* Stats tech dashboard */}
|
||||||
|
|||||||
157
components/ui/TagDisplay.tsx
Normal file
157
components/ui/TagDisplay.tsx
Normal 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
184
components/ui/TagInput.tsx
Normal 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
91
components/ui/TagList.tsx
Normal 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
223
hooks/useTags.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -162,9 +162,6 @@ export function useTasks(
|
|||||||
|
|
||||||
// 3. Appel API en arrière-plan
|
// 3. Appel API en arrière-plan
|
||||||
try {
|
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);
|
const response = await tasksClient.updateTask(data);
|
||||||
|
|
||||||
// Si l'API retourne des données différentes, on met à jour
|
// Si l'API retourne des données différentes, on met à jour
|
||||||
|
|||||||
@@ -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");
|
||||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal 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"
|
||||||
@@ -18,7 +18,6 @@ model Task {
|
|||||||
priority String @default("medium")
|
priority String @default("medium")
|
||||||
source String // "reminders" | "jira"
|
source String // "reminders" | "jira"
|
||||||
sourceId String? // ID dans le système source
|
sourceId String? // ID dans le système source
|
||||||
tagsJson String @default("[]") // JSON string des tags pour compatibilité
|
|
||||||
dueDate DateTime?
|
dueDate DateTime?
|
||||||
completedAt DateTime?
|
completedAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|||||||
@@ -48,6 +48,13 @@ async function resetDatabase() {
|
|||||||
console.log('');
|
console.log('');
|
||||||
console.log('📋 Tâches restantes:');
|
console.log('📋 Tâches restantes:');
|
||||||
const remainingTasks = await prisma.task.findMany({
|
const remainingTasks = await prisma.task.findMany({
|
||||||
|
include: {
|
||||||
|
taskTags: {
|
||||||
|
include: {
|
||||||
|
tag: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
orderBy: { createdAt: 'desc' }
|
orderBy: { createdAt: 'desc' }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -59,7 +66,9 @@ async function resetDatabase() {
|
|||||||
'cancelled': '❌'
|
'cancelled': '❌'
|
||||||
}[task.status] || '❓';
|
}[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(', ')}]` : '';
|
const tagsStr = tags.length > 0 ? ` [${tags.join(', ')}]` : '';
|
||||||
|
|
||||||
console.log(` ${index + 1}. ${statusEmoji} ${task.title}${tagsStr}`);
|
console.log(` ${index + 1}. ${statusEmoji} ${task.title}${tagsStr}`);
|
||||||
|
|||||||
@@ -52,7 +52,8 @@ async function seedTestData() {
|
|||||||
const priorityEmoji = {
|
const priorityEmoji = {
|
||||||
'low': '🔵',
|
'low': '🔵',
|
||||||
'medium': '🟡',
|
'medium': '🟡',
|
||||||
'high': '🔴'
|
'high': '🔴',
|
||||||
|
'urgent': '🚨'
|
||||||
}[task.priority];
|
}[task.priority];
|
||||||
|
|
||||||
console.log(` ${statusEmoji} ${priorityEmoji} ${task.title}`);
|
console.log(` ${statusEmoji} ${priorityEmoji} ${task.title}`);
|
||||||
|
|||||||
35
scripts/seed-tags.ts
Normal file
35
scripts/seed-tags.ts
Normal 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
237
services/tags.ts
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -31,6 +31,13 @@ export class TasksService {
|
|||||||
|
|
||||||
const tasks = await prisma.task.findMany({
|
const tasks = await prisma.task.findMany({
|
||||||
where,
|
where,
|
||||||
|
include: {
|
||||||
|
taskTags: {
|
||||||
|
include: {
|
||||||
|
tag: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
take: filters?.limit || 100,
|
take: filters?.limit || 100,
|
||||||
skip: filters?.offset || 0,
|
skip: filters?.offset || 0,
|
||||||
orderBy: [
|
orderBy: [
|
||||||
@@ -60,19 +67,37 @@ export class TasksService {
|
|||||||
description: taskData.description,
|
description: taskData.description,
|
||||||
status: taskData.status || 'todo',
|
status: taskData.status || 'todo',
|
||||||
priority: taskData.priority || 'medium',
|
priority: taskData.priority || 'medium',
|
||||||
tagsJson: JSON.stringify(taskData.tags || []),
|
|
||||||
dueDate: taskData.dueDate,
|
dueDate: taskData.dueDate,
|
||||||
source: 'manual', // Source manuelle
|
source: 'manual', // Source manuelle
|
||||||
sourceId: `manual-${Date.now()}` // ID unique
|
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) {
|
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()
|
updatedAt: new Date()
|
||||||
};
|
};
|
||||||
|
|
||||||
if (updates.tags) {
|
|
||||||
updateData.tagsJson = JSON.stringify(updates.tags);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (updates.status === 'done' && !task.completedAt) {
|
if (updates.status === 'done' && !task.completedAt) {
|
||||||
updateData.completedAt = new Date();
|
updateData.completedAt = new Date();
|
||||||
@@ -114,17 +136,29 @@ export class TasksService {
|
|||||||
updateData.completedAt = null;
|
updateData.completedAt = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedTask = await prisma.task.update({
|
await prisma.task.update({
|
||||||
where: { id: taskId },
|
where: { id: taskId },
|
||||||
data: updateData
|
data: updateData
|
||||||
});
|
});
|
||||||
|
|
||||||
// Gérer les tags
|
// Mettre à jour les relations avec les tags
|
||||||
if (updates.tags && updates.tags.length > 0) {
|
if (updates.tags !== undefined) {
|
||||||
await this.processTags(updates.tags);
|
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) {
|
for (const tagName of tagNames) {
|
||||||
try {
|
try {
|
||||||
await prisma.tag.upsert({
|
// Créer ou récupérer le tag
|
||||||
|
const tag = await prisma.tag.upsert({
|
||||||
where: { name: tagName },
|
where: { name: tagName },
|
||||||
update: {}, // Pas de mise à jour nécessaire
|
update: {}, // Pas de mise à jour nécessaire
|
||||||
create: {
|
create: {
|
||||||
@@ -187,12 +222,42 @@ export class TasksService {
|
|||||||
color: this.generateTagColor(tagName)
|
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) {
|
} 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
|
* 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
|
* 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 {
|
return {
|
||||||
id: prismaTask.id,
|
id: prismaTask.id,
|
||||||
title: prismaTask.title,
|
title: prismaTask.title,
|
||||||
@@ -224,8 +305,8 @@ export class TasksService {
|
|||||||
status: prismaTask.status as TaskStatus,
|
status: prismaTask.status as TaskStatus,
|
||||||
priority: prismaTask.priority as TaskPriority,
|
priority: prismaTask.priority as TaskPriority,
|
||||||
source: prismaTask.source as TaskSource,
|
source: prismaTask.source as TaskSource,
|
||||||
sourceId: prismaTask.sourceId?? undefined,
|
sourceId: prismaTask.sourceId ?? undefined,
|
||||||
tags: JSON.parse(prismaTask.tagsJson || '[]'),
|
tags: tags,
|
||||||
dueDate: prismaTask.dueDate ?? undefined,
|
dueDate: prismaTask.dueDate ?? undefined,
|
||||||
completedAt: prismaTask.completedAt ?? undefined,
|
completedAt: prismaTask.completedAt ?? undefined,
|
||||||
createdAt: prismaTask.createdAt,
|
createdAt: prismaTask.createdAt,
|
||||||
|
|||||||
144
src/app/api/tags/[id]/route.ts
Normal file
144
src/app/api/tags/[id]/route.ts
Normal 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
100
src/app/api/tags/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
240
src/app/tags/TagsPageClient.tsx
Normal file
240
src/app/tags/TagsPageClient.tsx
Normal 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
11
src/app/tags/page.tsx
Normal 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} />
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import { createContext, useContext, ReactNode } from 'react';
|
import { createContext, useContext, ReactNode } from 'react';
|
||||||
import { useTasks } from '@/hooks/useTasks';
|
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';
|
import { CreateTaskData, UpdateTaskData, TaskFilters } from '@/clients/tasks-client';
|
||||||
|
|
||||||
interface TasksContextType {
|
interface TasksContextType {
|
||||||
@@ -23,6 +24,10 @@ interface TasksContextType {
|
|||||||
deleteTask: (taskId: string) => Promise<void>;
|
deleteTask: (taskId: string) => Promise<void>;
|
||||||
refreshTasks: () => Promise<void>;
|
refreshTasks: () => Promise<void>;
|
||||||
setFilters: (filters: TaskFilters) => void;
|
setFilters: (filters: TaskFilters) => void;
|
||||||
|
// Tags
|
||||||
|
tags: Tag[];
|
||||||
|
tagsLoading: boolean;
|
||||||
|
tagsError: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TasksContext = createContext<TasksContextType | null>(null);
|
const TasksContext = createContext<TasksContextType | null>(null);
|
||||||
@@ -39,8 +44,17 @@ export function TasksProvider({ children, initialTasks, initialStats }: TasksPro
|
|||||||
{ tasks: initialTasks, stats: initialStats }
|
{ tasks: initialTasks, stats: initialStats }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { tags, loading: tagsLoading, error: tagsError } = useTags();
|
||||||
|
|
||||||
|
const contextValue: TasksContextType = {
|
||||||
|
...tasksState,
|
||||||
|
tags,
|
||||||
|
tagsLoading,
|
||||||
|
tagsError
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TasksContext.Provider value={tasksState}>
|
<TasksContext.Provider value={contextValue}>
|
||||||
{children}
|
{children}
|
||||||
</TasksContext.Provider>
|
</TasksContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user