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:
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({
|
||||
where,
|
||||
include: {
|
||||
taskTags: {
|
||||
include: {
|
||||
tag: true
|
||||
}
|
||||
}
|
||||
},
|
||||
take: filters?.limit || 100,
|
||||
skip: filters?.offset || 0,
|
||||
orderBy: [
|
||||
@@ -60,19 +67,37 @@ export class TasksService {
|
||||
description: taskData.description,
|
||||
status: taskData.status || 'todo',
|
||||
priority: taskData.priority || 'medium',
|
||||
tagsJson: JSON.stringify(taskData.tags || []),
|
||||
dueDate: taskData.dueDate,
|
||||
source: 'manual', // Source manuelle
|
||||
sourceId: `manual-${Date.now()}` // ID unique
|
||||
},
|
||||
include: {
|
||||
taskTags: {
|
||||
include: {
|
||||
tag: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Gérer les tags
|
||||
// Créer les relations avec les tags
|
||||
if (taskData.tags && taskData.tags.length > 0) {
|
||||
await this.processTags(taskData.tags);
|
||||
await this.createTaskTagRelations(task.id, taskData.tags);
|
||||
}
|
||||
|
||||
return this.mapPrismaTaskToTask(task);
|
||||
// Récupérer la tâche avec les tags pour le retour
|
||||
const taskWithTags = await prisma.task.findUnique({
|
||||
where: { id: task.id },
|
||||
include: {
|
||||
taskTags: {
|
||||
include: {
|
||||
tag: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return this.mapPrismaTaskToTask(taskWithTags!);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,9 +129,6 @@ export class TasksService {
|
||||
updatedAt: new Date()
|
||||
};
|
||||
|
||||
if (updates.tags) {
|
||||
updateData.tagsJson = JSON.stringify(updates.tags);
|
||||
}
|
||||
|
||||
if (updates.status === 'done' && !task.completedAt) {
|
||||
updateData.completedAt = new Date();
|
||||
@@ -114,17 +136,29 @@ export class TasksService {
|
||||
updateData.completedAt = null;
|
||||
}
|
||||
|
||||
const updatedTask = await prisma.task.update({
|
||||
await prisma.task.update({
|
||||
where: { id: taskId },
|
||||
data: updateData
|
||||
});
|
||||
|
||||
// Gérer les tags
|
||||
if (updates.tags && updates.tags.length > 0) {
|
||||
await this.processTags(updates.tags);
|
||||
// Mettre à jour les relations avec les tags
|
||||
if (updates.tags !== undefined) {
|
||||
await this.updateTaskTagRelations(taskId, updates.tags);
|
||||
}
|
||||
|
||||
return this.mapPrismaTaskToTask(updatedTask);
|
||||
// Récupérer la tâche avec les tags pour le retour
|
||||
const taskWithTags = await prisma.task.findUnique({
|
||||
where: { id: taskId },
|
||||
include: {
|
||||
taskTags: {
|
||||
include: {
|
||||
tag: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return this.mapPrismaTaskToTask(taskWithTags!);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -174,12 +208,13 @@ export class TasksService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Traite et crée les tags s'ils n'existent pas
|
||||
* Crée les relations TaskTag pour une tâche
|
||||
*/
|
||||
private async processTags(tagNames: string[]): Promise<void> {
|
||||
private async createTaskTagRelations(taskId: string, tagNames: string[]): Promise<void> {
|
||||
for (const tagName of tagNames) {
|
||||
try {
|
||||
await prisma.tag.upsert({
|
||||
// Créer ou récupérer le tag
|
||||
const tag = await prisma.tag.upsert({
|
||||
where: { name: tagName },
|
||||
update: {}, // Pas de mise à jour nécessaire
|
||||
create: {
|
||||
@@ -187,12 +222,42 @@ export class TasksService {
|
||||
color: this.generateTagColor(tagName)
|
||||
}
|
||||
});
|
||||
|
||||
// Créer la relation TaskTag si elle n'existe pas
|
||||
await prisma.taskTag.upsert({
|
||||
where: {
|
||||
taskId_tagId: {
|
||||
taskId: taskId,
|
||||
tagId: tag.id
|
||||
}
|
||||
},
|
||||
update: {}, // Pas de mise à jour nécessaire
|
||||
create: {
|
||||
taskId: taskId,
|
||||
tagId: tag.id
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Erreur lors de la création du tag ${tagName}:`, error);
|
||||
console.error(`Erreur lors de la création de la relation tag ${tagName}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour les relations TaskTag pour une tâche
|
||||
*/
|
||||
private async updateTaskTagRelations(taskId: string, tagNames: string[]): Promise<void> {
|
||||
// Supprimer toutes les relations existantes
|
||||
await prisma.taskTag.deleteMany({
|
||||
where: { taskId: taskId }
|
||||
});
|
||||
|
||||
// Créer les nouvelles relations
|
||||
if (tagNames.length > 0) {
|
||||
await this.createTaskTagRelations(taskId, tagNames);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère une couleur pour un tag basée sur son nom
|
||||
*/
|
||||
@@ -216,7 +281,23 @@ export class TasksService {
|
||||
/**
|
||||
* Convertit une tâche Prisma en objet Task
|
||||
*/
|
||||
private mapPrismaTaskToTask(prismaTask: Prisma.TaskGetPayload<object>): Task {
|
||||
private mapPrismaTaskToTask(prismaTask: Prisma.TaskGetPayload<{
|
||||
include: {
|
||||
taskTags: {
|
||||
include: {
|
||||
tag: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}> | Prisma.TaskGetPayload<object>): Task {
|
||||
// Extraire les tags depuis les relations TaskTag ou fallback sur tagsJson
|
||||
let tags: string[] = [];
|
||||
|
||||
if ('taskTags' in prismaTask && prismaTask.taskTags && Array.isArray(prismaTask.taskTags)) {
|
||||
// Utiliser les relations Prisma
|
||||
tags = prismaTask.taskTags.map((tt) => tt.tag.name);
|
||||
}
|
||||
|
||||
return {
|
||||
id: prismaTask.id,
|
||||
title: prismaTask.title,
|
||||
@@ -224,8 +305,8 @@ export class TasksService {
|
||||
status: prismaTask.status as TaskStatus,
|
||||
priority: prismaTask.priority as TaskPriority,
|
||||
source: prismaTask.source as TaskSource,
|
||||
sourceId: prismaTask.sourceId?? undefined,
|
||||
tags: JSON.parse(prismaTask.tagsJson || '[]'),
|
||||
sourceId: prismaTask.sourceId ?? undefined,
|
||||
tags: tags,
|
||||
dueDate: prismaTask.dueDate ?? undefined,
|
||||
completedAt: prismaTask.completedAt ?? undefined,
|
||||
createdAt: prismaTask.createdAt,
|
||||
|
||||
Reference in New Issue
Block a user