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

View File

@@ -1,5 +1,4 @@
import { Tag } from '@/lib/types'; import { Tag } from '@/lib/types';
import { Button } from '@/components/ui/Button';
interface TagListProps { interface TagListProps {
tags: (Tag & { usage?: number })[]; tags: (Tag & { usage?: number })[];
@@ -7,13 +6,15 @@ interface TagListProps {
onTagDelete?: (tag: Tag) => void; onTagDelete?: (tag: Tag) => void;
showActions?: boolean; showActions?: boolean;
showUsage?: boolean; showUsage?: boolean;
deletingTagId?: string | null;
} }
export function TagList({ export function TagList({
tags, tags,
onTagEdit, onTagEdit,
onTagDelete, onTagDelete,
showActions = true showActions = true,
deletingTagId
}: TagListProps) { }: TagListProps) {
if (tags.length === 0) { if (tags.length === 0) {
return ( return (
@@ -27,10 +28,15 @@ export function TagList({
return ( return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"> <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 <div
key={tag.id} 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 */} {/* Contenu principal */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -57,24 +63,21 @@ export function TagList({
{showActions && (onTagEdit || onTagDelete) && ( {showActions && (onTagEdit || onTagDelete) && (
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{onTagEdit && ( {onTagEdit && (
<Button <button
variant="ghost"
size="sm"
onClick={() => onTagEdit(tag)} 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 && ( {onTagDelete && (
<Button <button
variant="ghost"
size="sm"
onClick={() => onTagDelete(tag)} 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"
> >
🗑 {isDeleting ? '⏳' : '🗑️'}
</Button> </button>
)} )}
</div> </div>
)} )}
@@ -85,7 +88,8 @@ export function TagList({
style={{ backgroundColor: tag.color }} style={{ backgroundColor: tag.color }}
/> />
</div> </div>
))} );
})}
</div> </div>
); );
} }

View File

@@ -222,10 +222,12 @@ export function useTasks(
} }
}, [refreshTasks]); }, [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(() => { useEffect(() => {
refreshTasks(); if (!initialData?.tasks?.length) {
}, [refreshTasks]); refreshTasks();
}
}, [refreshTasks, initialData?.tasks?.length]);
return { return {
...state, ...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> { async deleteTag(id: string): Promise<void> {
// Vérifier que le tag existe // Vérifier que le tag existe
@@ -142,15 +142,12 @@ export const tagsService = {
throw new Error(`Tag avec l'ID "${id}" non trouvé`); 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 // Supprimer d'abord toutes les relations TaskTag
const taskTagCount = await prisma.taskTag.count({ await prisma.taskTag.deleteMany({
where: { tagId: id } where: { tagId: id }
}); });
if (taskTagCount > 0) { // Puis supprimer le tag lui-même
throw new Error(`Impossible de supprimer le tag "${existing.name}" car il est utilisé par ${taskTagCount} tâche(s)`);
}
await prisma.tag.delete({ await prisma.tag.delete({
where: { id } 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, skip: filters?.offset || 0,
orderBy: [ orderBy: [
{ completedAt: 'desc' }, { completedAt: 'desc' },

View File

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

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import React from 'react';
import { Tag } from '@/lib/types'; import { Tag } from '@/lib/types';
import { useTags } from '@/hooks/useTags'; import { useTags } from '@/hooks/useTags';
import { CreateTagData, UpdateTagData } from '@/clients/tags-client'; import { CreateTagData, UpdateTagData } from '@/clients/tags-client';
@@ -32,11 +33,20 @@ export function TagsPageClient({ initialTags }: TagsPageClientProps) {
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [editingTag, setEditingTag] = useState<Tag | null>(null); const [editingTag, setEditingTag] = useState<Tag | null>(null);
const [showPopular, setShowPopular] = useState(false); 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 // Utiliser les tags du hook s'ils sont chargés, sinon les tags locaux
const displayTags = tags.length > 0 ? tags : initialTags; const displayTags = tags.length > 0 ? tags : localTags;
const filteredTags = searchQuery ? searchResults : displayTags; 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) => { const handleSearch = async (query: string) => {
setSearchQuery(query); setSearchQuery(query);
if (query.trim()) { if (query.trim()) {
@@ -67,13 +77,22 @@ export function TagsPageClient({ initialTags }: TagsPageClientProps) {
}; };
const handleDeleteTag = async (tag: Tag) => { const handleDeleteTag = async (tag: Tag) => {
if (confirm(`Êtes-vous sûr de vouloir supprimer le tag "${tag.name}" ?`)) { // Suppression optimiste : retirer immédiatement de l'affichage
try { setDeletingTagId(tag.id);
await deleteTag(tag.id);
} catch (error) { // Mettre à jour les tags locaux pour suppression immédiate
// L'erreur est déjà gérée dans le hook const updatedTags = displayTags.filter(t => t.id !== tag.id);
console.error('Erreur lors de la suppression:', error); 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 <Button
variant="secondary" variant="secondary"
onClick={handleShowPopular} onClick={handleShowPopular}
disabled={loading} disabled={false}
> >
{showPopular ? 'Tous les tags' : 'Tags populaires'} {showPopular ? 'Tous les tags' : 'Tags populaires'}
</Button> </Button>
<Button <Button
variant="primary" variant="primary"
onClick={() => setIsCreateModalOpen(true)} onClick={() => setIsCreateModalOpen(true)}
disabled={loading} disabled={false}
> >
+ Nouveau tag + Nouveau tag
</Button> </Button>
@@ -194,7 +213,7 @@ export function TagsPageClient({ initialTags }: TagsPageClientProps) {
</div> </div>
)} )}
{loading && ( {loading && displayTags.length === 0 && (
<div className="text-center py-8"> <div className="text-center py-8">
<div className="text-slate-400">Chargement...</div> <div className="text-slate-400">Chargement...</div>
</div> </div>
@@ -215,6 +234,7 @@ export function TagsPageClient({ initialTags }: TagsPageClientProps) {
onTagEdit={handleEditTag} onTagEdit={handleEditTag}
onTagDelete={handleDeleteTag} onTagDelete={handleDeleteTag}
showUsage={true} showUsage={true}
deletingTagId={deletingTagId}
/> />
</div> </div>
</div> </div>
@@ -225,7 +245,7 @@ export function TagsPageClient({ initialTags }: TagsPageClientProps) {
isOpen={isCreateModalOpen} isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)} onClose={() => setIsCreateModalOpen(false)}
onSubmit={handleCreateTag} onSubmit={handleCreateTag}
loading={loading} loading={false}
/> />
<TagForm <TagForm
@@ -233,7 +253,7 @@ export function TagsPageClient({ initialTags }: TagsPageClientProps) {
onClose={() => setEditingTag(null)} onClose={() => setEditingTag(null)}
onSubmit={handleUpdateTag} onSubmit={handleUpdateTag}
tag={editingTag} tag={editingTag}
loading={loading} loading={false}
/> />
</div> </div>
); );