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:
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user