feat: improve TaskCard and TagList components, enhance task loading logic

- Updated TaskCard to conditionally render footer elements based on available data (due date, source, completion status).
- Enhanced TagList to visually indicate deleting tags and improved button styles for better UX.
- Modified useTasks hook to refresh tasks only if no initial data is present, optimizing loading behavior.
- Updated TagsPageClient to manage local tags and handle optimistic UI updates during tag deletion.
This commit is contained in:
Julien Froidefond
2025-09-14 17:22:06 +02:00
parent 5e09759c2b
commit c1844cfb71
7 changed files with 94 additions and 67 deletions

View File

@@ -186,32 +186,36 @@ export function TaskCard({ task, onDelete, onEdit, onUpdateTitle }: TaskCardProp
</div>
)}
{/* Footer tech avec séparateur néon */}
<div className="pt-2 border-t border-slate-700/50">
<div className="flex items-center justify-between text-xs">
{task.dueDate ? (
<span className="flex items-center gap-1 text-slate-400 font-mono">
<span className="text-cyan-400"></span>
{formatDistanceToNow(new Date(task.dueDate), {
addSuffix: true,
locale: fr
})}
</span>
) : (
<span className="text-slate-600 font-mono">--:--</span>
)}
{task.source !== 'manual' && task.source && (
<Badge variant="outline" size="sm">
{task.source}
</Badge>
)}
{task.completedAt && (
<span className="text-emerald-400 font-mono font-bold"> DONE</span>
)}
{/* Footer tech avec séparateur néon - seulement si des données à afficher */}
{(task.dueDate || (task.source && task.source !== 'manual') || task.completedAt) && (
<div className="pt-2 border-t border-slate-700/50">
<div className="flex items-center justify-between text-xs">
{task.dueDate ? (
<span className="flex items-center gap-1 text-slate-400 font-mono">
<span className="text-cyan-400"></span>
{formatDistanceToNow(new Date(task.dueDate), {
addSuffix: true,
locale: fr
})}
</span>
) : (
<div></div>
)}
<div className="flex items-center gap-2">
{task.source !== 'manual' && task.source && (
<Badge variant="outline" size="sm">
{task.source}
</Badge>
)}
{task.completedAt && (
<span className="text-emerald-400 font-mono font-bold"> DONE</span>
)}
</div>
</div>
</div>
</div>
)}
</Card>
);
}

View File

@@ -1,5 +1,4 @@
import { Tag } from '@/lib/types';
import { Button } from '@/components/ui/Button';
interface TagListProps {
tags: (Tag & { usage?: number })[];
@@ -7,13 +6,15 @@ interface TagListProps {
onTagDelete?: (tag: Tag) => void;
showActions?: boolean;
showUsage?: boolean;
deletingTagId?: string | null;
}
export function TagList({
tags,
onTagEdit,
onTagDelete,
showActions = true
showActions = true,
deletingTagId
}: TagListProps) {
if (tags.length === 0) {
return (
@@ -27,10 +28,15 @@ export function TagList({
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{tags.map((tag) => (
{tags.map((tag) => {
const isDeleting = deletingTagId === tag.id;
return (
<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"
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 ${
isDeleting ? 'opacity-50 pointer-events-none' : ''
}`}
>
{/* Contenu principal */}
<div className="flex items-center gap-3">
@@ -57,24 +63,21 @@ export function TagList({
{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"
<button
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"
className="h-7 px-2 text-xs bg-slate-800/50 backdrop-blur-sm border border-slate-700 hover:border-slate-600 hover:bg-slate-700/50 rounded-md transition-all duration-200 text-slate-300 hover:text-slate-200"
>
</Button>
</button>
)}
{onTagDelete && (
<Button
variant="ghost"
size="sm"
<button
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"
disabled={isDeleting}
className="h-7 px-2 text-xs bg-slate-800/50 backdrop-blur-sm border border-slate-700 hover:border-red-500/50 hover:text-red-400 hover:bg-red-900/20 rounded-md transition-all duration-200 text-slate-300 disabled:opacity-50 disabled:cursor-not-allowed"
>
🗑
</Button>
{isDeleting ? '⏳' : '🗑️'}
</button>
)}
</div>
)}
@@ -85,7 +88,8 @@ export function TagList({
style={{ backgroundColor: tag.color }}
/>
</div>
))}
);
})}
</div>
);
}

View File

@@ -222,10 +222,12 @@ export function useTasks(
}
}, [refreshTasks]);
// Charger les tâches au montage et quand les filtres changent
// Charger les tâches au montage seulement si pas de données initiales
useEffect(() => {
refreshTasks();
}, [refreshTasks]);
if (!initialData?.tasks?.length) {
refreshTasks();
}
}, [refreshTasks, initialData?.tasks?.length]);
return {
...state,

View File

@@ -133,7 +133,7 @@ export const tagsService = {
},
/**
* Supprime un tag
* Supprime un tag et toutes ses relations
*/
async deleteTag(id: string): Promise<void> {
// Vérifier que le tag existe
@@ -142,15 +142,12 @@ export const tagsService = {
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({
// Supprimer d'abord toutes les relations TaskTag
await prisma.taskTag.deleteMany({
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)`);
}
// Puis supprimer le tag lui-même
await prisma.tag.delete({
where: { id }
});

View File

@@ -38,7 +38,7 @@ export class TasksService {
}
}
},
take: filters?.limit || 100,
take: filters?.limit, // Pas de limite par défaut - récupère toutes les tâches
skip: filters?.offset || 0,
orderBy: [
{ completedAt: 'desc' },

View File

@@ -4,7 +4,7 @@ import { HomePageClient } from '@/components/HomePageClient';
export default async function HomePage() {
// SSR - Récupération des données côté serveur
const [initialTasks, initialStats] = await Promise.all([
tasksService.getTasks({ limit: 20 }),
tasksService.getTasks(),
tasksService.getTaskStats()
]);

View File

@@ -1,6 +1,7 @@
'use client';
import { useState } from 'react';
import React from 'react';
import { Tag } from '@/lib/types';
import { useTags } from '@/hooks/useTags';
import { CreateTagData, UpdateTagData } from '@/clients/tags-client';
@@ -32,11 +33,20 @@ export function TagsPageClient({ initialTags }: TagsPageClientProps) {
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [editingTag, setEditingTag] = useState<Tag | null>(null);
const [showPopular, setShowPopular] = useState(false);
const [deletingTagId, setDeletingTagId] = useState<string | null>(null);
const [localTags, setLocalTags] = useState<Tag[]>(initialTags);
// Utiliser les tags initiaux si pas encore chargés
const displayTags = tags.length > 0 ? tags : initialTags;
// Utiliser les tags du hook s'ils sont chargés, sinon les tags locaux
const displayTags = tags.length > 0 ? tags : localTags;
const filteredTags = searchQuery ? searchResults : displayTags;
// Synchroniser les tags locaux avec les tags du hook une seule fois
React.useEffect(() => {
if (tags.length > 0 && localTags === initialTags) {
setLocalTags(tags);
}
}, [tags, localTags, initialTags]);
const handleSearch = async (query: string) => {
setSearchQuery(query);
if (query.trim()) {
@@ -67,13 +77,22 @@ export function TagsPageClient({ initialTags }: TagsPageClientProps) {
};
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);
}
// Suppression optimiste : retirer immédiatement de l'affichage
setDeletingTagId(tag.id);
// Mettre à jour les tags locaux pour suppression immédiate
const updatedTags = displayTags.filter(t => t.id !== tag.id);
setLocalTags(updatedTags);
try {
await deleteTag(tag.id);
// Succès : les tags seront mis à jour par le hook
} catch (error) {
// En cas d'erreur, restaurer le tag dans l'affichage
setLocalTags(prev => [...prev, tag].sort((a, b) => a.name.localeCompare(b.name)));
console.error('Erreur lors de la suppression:', error);
} finally {
setDeletingTagId(null);
}
};
@@ -122,14 +141,14 @@ export function TagsPageClient({ initialTags }: TagsPageClientProps) {
<Button
variant="secondary"
onClick={handleShowPopular}
disabled={loading}
disabled={false}
>
{showPopular ? 'Tous les tags' : 'Tags populaires'}
</Button>
<Button
variant="primary"
onClick={() => setIsCreateModalOpen(true)}
disabled={loading}
disabled={false}
>
+ Nouveau tag
</Button>
@@ -194,7 +213,7 @@ export function TagsPageClient({ initialTags }: TagsPageClientProps) {
</div>
)}
{loading && (
{loading && displayTags.length === 0 && (
<div className="text-center py-8">
<div className="text-slate-400">Chargement...</div>
</div>
@@ -215,6 +234,7 @@ export function TagsPageClient({ initialTags }: TagsPageClientProps) {
onTagEdit={handleEditTag}
onTagDelete={handleDeleteTag}
showUsage={true}
deletingTagId={deletingTagId}
/>
</div>
</div>
@@ -225,7 +245,7 @@ export function TagsPageClient({ initialTags }: TagsPageClientProps) {
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
onSubmit={handleCreateTag}
loading={loading}
loading={false}
/>
<TagForm
@@ -233,7 +253,7 @@ export function TagsPageClient({ initialTags }: TagsPageClientProps) {
onClose={() => setEditingTag(null)}
onSubmit={handleUpdateTag}
tag={editingTag}
loading={loading}
loading={false}
/>
</div>
);