feat: enhance GeneralSettingsPage with tag management
- Added tag management functionality to the `GeneralSettingsPageClient`, including filtering, sorting, and CRUD operations for tags. - Integrated a modal for creating and editing tags, improving user experience in managing task labels. - Updated the `Header` component to replace the 'Tags' link with 'Manager'. - Removed the deprecated `TagsPage` and `TagsPageClient` components to streamline the codebase. - Adjusted data fetching in `page.tsx` to include initial tags alongside user preferences.
This commit is contained in:
@@ -1,16 +1,85 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { UserPreferences } from '@/lib/types';
|
import { useState, useMemo } from 'react';
|
||||||
|
import { UserPreferences, Tag } from '@/lib/types';
|
||||||
|
import { useTags } from '@/hooks/useTags';
|
||||||
import { Header } from '@/components/ui/Header';
|
import { Header } from '@/components/ui/Header';
|
||||||
import { Card, CardContent } from '@/components/ui/Card';
|
import { Card, CardContent, CardHeader } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { TagForm } from '@/components/forms/TagForm';
|
||||||
import { UserPreferencesProvider } from '@/contexts/UserPreferencesContext';
|
import { UserPreferencesProvider } from '@/contexts/UserPreferencesContext';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
interface GeneralSettingsPageClientProps {
|
interface GeneralSettingsPageClientProps {
|
||||||
initialPreferences: UserPreferences;
|
initialPreferences: UserPreferences;
|
||||||
|
initialTags: Tag[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GeneralSettingsPageClient({ initialPreferences }: GeneralSettingsPageClientProps) {
|
export function GeneralSettingsPageClient({ initialPreferences, initialTags }: GeneralSettingsPageClientProps) {
|
||||||
|
const {
|
||||||
|
tags,
|
||||||
|
refreshTags,
|
||||||
|
deleteTag
|
||||||
|
} = useTags(initialTags as (Tag & { usage: number })[]);
|
||||||
|
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [showOnlyUnused, setShowOnlyUnused] = useState(false);
|
||||||
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||||
|
const [editingTag, setEditingTag] = useState<Tag | null>(null);
|
||||||
|
const [deletingTagId, setDeletingTagId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Filtrer et trier les tags
|
||||||
|
const filteredTags = useMemo(() => {
|
||||||
|
let filtered = tags;
|
||||||
|
|
||||||
|
// Filtrer par recherche
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
filtered = filtered.filter(tag =>
|
||||||
|
tag.name.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtrer pour afficher seulement les non utilisés
|
||||||
|
if (showOnlyUnused) {
|
||||||
|
filtered = filtered.filter(tag => {
|
||||||
|
const usage = (tag as Tag & { usage?: number }).usage || 0;
|
||||||
|
return usage === 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = filtered.sort((a, b) => {
|
||||||
|
const usageA = (a as Tag & { usage?: number }).usage || 0;
|
||||||
|
const usageB = (b as Tag & { usage?: number }).usage || 0;
|
||||||
|
if (usageB !== usageA) return usageB - usageA;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Limiter à 12 tags si pas de recherche ni filtre, sinon afficher tous les résultats
|
||||||
|
const hasFilters = searchQuery.trim() || showOnlyUnused;
|
||||||
|
return hasFilters ? sorted : sorted.slice(0, 12);
|
||||||
|
}, [tags, searchQuery, showOnlyUnused]);
|
||||||
|
|
||||||
|
const handleEditTag = (tag: Tag) => {
|
||||||
|
setEditingTag(tag);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteTag = async (tag: Tag) => {
|
||||||
|
if (!confirm(`Êtes-vous sûr de vouloir supprimer le tag "${tag.name}" ?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDeletingTagId(tag.id);
|
||||||
|
try {
|
||||||
|
await deleteTag(tag.id);
|
||||||
|
await refreshTags();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de la suppression:', error);
|
||||||
|
} finally {
|
||||||
|
setDeletingTagId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<UserPreferencesProvider initialPreferences={initialPreferences}>
|
<UserPreferencesProvider initialPreferences={initialPreferences}>
|
||||||
<div className="min-h-screen bg-[var(--background)]">
|
<div className="min-h-screen bg-[var(--background)]">
|
||||||
@@ -41,6 +110,210 @@ export function GeneralSettingsPageClient({ initialPreferences }: GeneralSetting
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{/* Gestion des tags */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||||
|
🏷️ Gestion des tags
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)] mt-1">
|
||||||
|
Créer et organiser les étiquettes pour vos tâches
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsCreateModalOpen(true)}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
Nouveau tag
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{/* Stats des tags */}
|
||||||
|
<div className="grid grid-cols-3 gap-4 mb-4">
|
||||||
|
<div className="text-center p-3 bg-[var(--muted)]/20 rounded">
|
||||||
|
<div className="text-xl font-bold text-[var(--foreground)]">{tags.length}</div>
|
||||||
|
<div className="text-sm text-[var(--muted-foreground)]">Tags créés</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-3 bg-[var(--primary)]/10 rounded">
|
||||||
|
<div className="text-xl font-bold text-[var(--primary)]">
|
||||||
|
{tags.reduce((sum, tag) => sum + ((tag as Tag & { usage?: number }).usage || 0), 0)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-[var(--muted-foreground)]">Utilisations</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-3 bg-[var(--success)]/10 rounded">
|
||||||
|
<div className="text-xl font-bold text-[var(--success)]">
|
||||||
|
{tags.filter(tag => (tag as Tag & { usage?: number }).usage && (tag as Tag & { usage?: number }).usage! > 0).length}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-[var(--muted-foreground)]">Actifs</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recherche et filtres */}
|
||||||
|
<div className="space-y-3 mb-4">
|
||||||
|
<Input
|
||||||
|
placeholder="Rechercher un tag..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Filtres rapides */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
variant={showOnlyUnused ? "primary" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowOnlyUnused(!showOnlyUnused)}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<span className="text-xs">⚠️</span>
|
||||||
|
Tags non utilisés ({tags.filter(tag => ((tag as Tag & { usage?: number }).usage || 0) === 0).length})
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{(searchQuery || showOnlyUnused) && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setSearchQuery('');
|
||||||
|
setShowOnlyUnused(false);
|
||||||
|
}}
|
||||||
|
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||||
|
>
|
||||||
|
Réinitialiser
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Liste des tags en grid */}
|
||||||
|
{filteredTags.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-[var(--muted-foreground)]">
|
||||||
|
{searchQuery && showOnlyUnused ? 'Aucun tag non utilisé trouvé avec cette recherche' :
|
||||||
|
searchQuery ? 'Aucun tag trouvé pour cette recherche' :
|
||||||
|
showOnlyUnused ? '🎉 Aucun tag non utilisé ! Tous vos tags sont actifs.' :
|
||||||
|
'Aucun tag créé'}
|
||||||
|
{!searchQuery && !showOnlyUnused && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsCreateModalOpen(true)}
|
||||||
|
>
|
||||||
|
Créer votre premier tag
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Grid des tags */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
|
{filteredTags.map((tag) => {
|
||||||
|
const usage = (tag as Tag & { usage?: number }).usage || 0;
|
||||||
|
const isUnused = usage === 0;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={tag.id}
|
||||||
|
className={`p-3 rounded-lg border transition-all hover:shadow-sm ${
|
||||||
|
isUnused
|
||||||
|
? 'border-[var(--destructive)]/30 bg-[var(--destructive)]/5 hover:border-[var(--destructive)]/50'
|
||||||
|
: 'border-[var(--border)] hover:border-[var(--primary)]/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Header du tag */}
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||||
|
style={{ backgroundColor: tag.color }}
|
||||||
|
/>
|
||||||
|
<span className="font-medium text-sm truncate">{tag.name}</span>
|
||||||
|
{tag.isPinned && (
|
||||||
|
<span className="text-xs px-1.5 py-0.5 bg-[var(--primary)]/20 text-[var(--primary)] rounded flex-shrink-0">
|
||||||
|
📌
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleEditTag(tag)}
|
||||||
|
className="h-7 w-7 p-0 text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||||
|
>
|
||||||
|
<svg className="w-3 h-3" 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>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDeleteTag(tag)}
|
||||||
|
disabled={deletingTagId === tag.id}
|
||||||
|
className={`h-7 w-7 p-0 ${
|
||||||
|
isUnused
|
||||||
|
? 'text-[var(--destructive)] hover:text-[var(--destructive)] hover:bg-[var(--destructive)]/20'
|
||||||
|
: 'text-[var(--muted-foreground)] hover:text-[var(--destructive)] hover:bg-[var(--destructive)]/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{deletingTagId === tag.id ? (
|
||||||
|
<svg className="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-3 h-3" 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>
|
||||||
|
|
||||||
|
{/* Stats et warning */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className={`text-xs flex items-center justify-between ${
|
||||||
|
isUnused ? 'text-[var(--destructive)]' : 'text-[var(--muted-foreground)]'
|
||||||
|
}`}>
|
||||||
|
<span>{usage} utilisation{usage !== 1 ? 's' : ''}</span>
|
||||||
|
{isUnused && (
|
||||||
|
<span className="text-xs px-1.5 py-0.5 bg-[var(--destructive)]/20 text-[var(--destructive)] rounded">
|
||||||
|
⚠️ Non utilisé
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{('createdAt' in tag && (tag as Tag & { createdAt: Date }).createdAt) && (
|
||||||
|
<div className="text-xs text-[var(--muted-foreground)]">
|
||||||
|
Créé le {new Date((tag as Tag & { createdAt: Date }).createdAt).toLocaleDateString('fr-FR')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Message si plus de tags */}
|
||||||
|
{tags.length > 12 && !searchQuery && !showOnlyUnused && (
|
||||||
|
<div className="text-center pt-2 text-sm text-[var(--muted-foreground)]">
|
||||||
|
Et {tags.length - 12} autres tags... (utilisez la recherche ou les filtres pour les voir)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Note développement futur */}
|
{/* Note développement futur */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
@@ -49,7 +322,7 @@ export function GeneralSettingsPageClient({ initialPreferences }: GeneralSetting
|
|||||||
🚧 Interface de configuration en développement
|
🚧 Interface de configuration en développement
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-[var(--muted-foreground)]">
|
<p className="text-xs text-[var(--muted-foreground)]">
|
||||||
Les contrôles interactifs pour modifier ces préférences seront disponibles dans une prochaine version.
|
Les contrôles interactifs pour modifier les autres préférences seront disponibles dans une prochaine version.
|
||||||
Pour l'instant, les préférences sont modifiables via les boutons de l'interface principale.
|
Pour l'instant, les préférences sont modifiables via les boutons de l'interface principale.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -59,6 +332,30 @@ export function GeneralSettingsPageClient({ initialPreferences }: GeneralSetting
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Modals pour les tags */}
|
||||||
|
{isCreateModalOpen && (
|
||||||
|
<TagForm
|
||||||
|
isOpen={isCreateModalOpen}
|
||||||
|
onClose={() => setIsCreateModalOpen(false)}
|
||||||
|
onSuccess={async () => {
|
||||||
|
setIsCreateModalOpen(false);
|
||||||
|
await refreshTags();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editingTag && (
|
||||||
|
<TagForm
|
||||||
|
isOpen={!!editingTag}
|
||||||
|
tag={editingTag}
|
||||||
|
onClose={() => setEditingTag(null)}
|
||||||
|
onSuccess={async () => {
|
||||||
|
setEditingTag(null);
|
||||||
|
await refreshTags();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</UserPreferencesProvider>
|
</UserPreferencesProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ export function Header({ title = "TowerControl", subtitle = "Task Management", s
|
|||||||
{ href: '/kanban', label: 'Kanban' },
|
{ href: '/kanban', label: 'Kanban' },
|
||||||
{ href: '/daily', label: 'Daily' },
|
{ href: '/daily', label: 'Daily' },
|
||||||
{ href: '/weekly-summary', label: 'Hebdo' },
|
{ href: '/weekly-summary', label: 'Hebdo' },
|
||||||
{ href: '/tags', label: 'Tags' },
|
|
||||||
...(isJiraConfigured ? [{ href: '/jira-dashboard', label: `Jira${jiraConfig?.projectKey ? ` (${jiraConfig.projectKey})` : ''}` }] : []),
|
...(isJiraConfigured ? [{ href: '/jira-dashboard', label: `Jira${jiraConfig?.projectKey ? ` (${jiraConfig.projectKey})` : ''}` }] : []),
|
||||||
{ href: '/settings', label: 'Settings' }
|
{ href: '/settings', label: 'Settings' }
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { userPreferencesService } from '@/services/user-preferences';
|
import { userPreferencesService } from '@/services/user-preferences';
|
||||||
|
import { tagsService } from '@/services/tags';
|
||||||
import { GeneralSettingsPageClient } from '@/components/settings/GeneralSettingsPageClient';
|
import { GeneralSettingsPageClient } from '@/components/settings/GeneralSettingsPageClient';
|
||||||
|
|
||||||
// Force dynamic rendering for real-time data
|
// Force dynamic rendering for real-time data
|
||||||
@@ -6,7 +7,10 @@ export const dynamic = 'force-dynamic';
|
|||||||
|
|
||||||
export default async function GeneralSettingsPage() {
|
export default async function GeneralSettingsPage() {
|
||||||
// Fetch data server-side
|
// Fetch data server-side
|
||||||
const preferences = await userPreferencesService.getAllPreferences();
|
const [preferences, tags] = await Promise.all([
|
||||||
|
userPreferencesService.getAllPreferences(),
|
||||||
|
tagsService.getTags()
|
||||||
|
]);
|
||||||
|
|
||||||
return <GeneralSettingsPageClient initialPreferences={preferences} />;
|
return <GeneralSettingsPageClient initialPreferences={preferences} initialTags={tags} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,224 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useMemo } from 'react';
|
|
||||||
import React from 'react';
|
|
||||||
import { Tag } from '@/lib/types';
|
|
||||||
import { useTags } from '@/hooks/useTags';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { Input } from '@/components/ui/Input';
|
|
||||||
import { TagForm } from '@/components/forms/TagForm';
|
|
||||||
import { Header } from '@/components/ui/Header';
|
|
||||||
|
|
||||||
interface TagsPageClientProps {
|
|
||||||
initialTags: Tag[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TagsPageClient({ initialTags }: TagsPageClientProps) {
|
|
||||||
const {
|
|
||||||
tags,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
refreshTags,
|
|
||||||
deleteTag
|
|
||||||
} = useTags(initialTags as (Tag & { usage: number })[]);
|
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
|
||||||
const [editingTag, setEditingTag] = useState<Tag | null>(null);
|
|
||||||
const [deletingTagId, setDeletingTagId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Filtrer et trier les tags
|
|
||||||
const filteredAndSortedTags = useMemo(() => {
|
|
||||||
let filtered = tags;
|
|
||||||
|
|
||||||
// Filtrer par recherche
|
|
||||||
if (searchQuery.trim()) {
|
|
||||||
const query = searchQuery.toLowerCase();
|
|
||||||
filtered = tags.filter(tag =>
|
|
||||||
tag.name.toLowerCase().includes(query)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trier par usage puis par nom
|
|
||||||
return filtered.sort((a, b) => {
|
|
||||||
const usageA = (a as Tag & { usage?: number }).usage || 0;
|
|
||||||
const usageB = (b as Tag & { usage?: number }).usage || 0;
|
|
||||||
if (usageB !== usageA) return usageB - usageA;
|
|
||||||
return a.name.localeCompare(b.name);
|
|
||||||
});
|
|
||||||
}, [tags, searchQuery]);
|
|
||||||
|
|
||||||
const handleEditTag = (tag: Tag) => {
|
|
||||||
setEditingTag(tag);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteTag = async (tag: Tag) => {
|
|
||||||
if (!confirm(`Êtes-vous sûr de vouloir supprimer le tag "${tag.name}" ?`)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setDeletingTagId(tag.id);
|
|
||||||
try {
|
|
||||||
// Utiliser la server action directement depuis useTags
|
|
||||||
await deleteTag(tag.id);
|
|
||||||
// Refresh la liste des tags
|
|
||||||
await refreshTags();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erreur lors de la suppression:', error);
|
|
||||||
} finally {
|
|
||||||
setDeletingTagId(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-[var(--background)]">
|
|
||||||
{/* Header uniforme */}
|
|
||||||
<Header
|
|
||||||
title="TowerControl"
|
|
||||||
subtitle="Tags - Gestion des étiquettes"
|
|
||||||
syncing={loading}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Header spécifique aux tags */}
|
|
||||||
<div className="bg-[var(--card)]/50 border-b border-[var(--border)]/30">
|
|
||||||
<div className="container mx-auto px-6 py-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<h2 className="text-lg font-mono font-bold text-[var(--foreground)] tracking-wider">
|
|
||||||
Tags ({filteredAndSortedTags.length})
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
onClick={() => setIsCreateModalOpen(true)}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
|
||||||
</svg>
|
|
||||||
Nouveau tag
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Contenu principal */}
|
|
||||||
<div className="container mx-auto px-6 py-6">
|
|
||||||
{/* Barre de recherche */}
|
|
||||||
<div className="max-w-md mx-auto mb-6">
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
placeholder="Rechercher un tag..."
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Messages d'état */}
|
|
||||||
{error && (
|
|
||||||
<div className="max-w-md mx-auto mb-6 bg-[var(--destructive)]/20 border border-[var(--destructive)]/30 rounded-lg p-3 text-center">
|
|
||||||
<div className="text-[var(--destructive)] text-sm">Erreur : {error}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loading && (
|
|
||||||
<div className="text-center py-8">
|
|
||||||
<div className="text-[var(--muted-foreground)]">Chargement...</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tags en grille compacte */}
|
|
||||||
{!loading && (
|
|
||||||
<div className="max-w-6xl mx-auto">
|
|
||||||
{filteredAndSortedTags.length === 0 ? (
|
|
||||||
<div className="text-center py-12 text-[var(--muted-foreground)]">
|
|
||||||
<div className="text-6xl mb-4">🏷️</div>
|
|
||||||
<p className="text-lg mb-2">
|
|
||||||
{searchQuery ? 'Aucun tag trouvé' : 'Aucun tag'}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm">
|
|
||||||
{searchQuery ? 'Essayez un autre terme' : 'Créez votre premier tag'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
|
||||||
{filteredAndSortedTags.map((tag) => {
|
|
||||||
const isDeleting = deletingTagId === tag.id;
|
|
||||||
const usage = (tag as Tag & { usage?: number }).usage || 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={tag.id}
|
|
||||||
className={`relative bg-[var(--card)] rounded-lg border border-[var(--border)] hover:border-[var(--border)] transition-all duration-200 p-4 group ${
|
|
||||||
isDeleting ? 'opacity-50 pointer-events-none' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{/* Actions en overlay */}
|
|
||||||
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
|
|
||||||
<button
|
|
||||||
onClick={() => handleEditTag(tag)}
|
|
||||||
className="p-1.5 text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors rounded-lg hover:bg-[var(--card-hover)]"
|
|
||||||
title="Modifier"
|
|
||||||
>
|
|
||||||
<svg className="w-3.5 h-3.5" 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>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDeleteTag(tag)}
|
|
||||||
className="p-1.5 text-[var(--muted-foreground)] hover:text-[var(--destructive)] transition-colors rounded-lg hover:bg-[var(--destructive)]/20"
|
|
||||||
title="Supprimer"
|
|
||||||
>
|
|
||||||
<svg className="w-3.5 h-3.5" 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>
|
|
||||||
|
|
||||||
{/* Contenu principal */}
|
|
||||||
<div className="flex items-start gap-3 pr-12">
|
|
||||||
<div
|
|
||||||
className="w-5 h-5 rounded-full flex-shrink-0 mt-0.5"
|
|
||||||
style={{ backgroundColor: tag.color }}
|
|
||||||
/>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h3 className="text-[var(--foreground)] font-medium truncate text-sm mb-1">
|
|
||||||
{tag.name}
|
|
||||||
</h3>
|
|
||||||
<p className="text-[var(--muted-foreground)] text-xs mb-2">
|
|
||||||
{usage} tâche{usage > 1 ? 's' : ''}
|
|
||||||
</p>
|
|
||||||
{tag.isPinned && (
|
|
||||||
<span className="inline-flex items-center text-[var(--accent)] text-xs bg-[var(--accent)]/10 px-2 py-1 rounded-full">
|
|
||||||
🎯 Objectif
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Modals */}
|
|
||||||
<TagForm
|
|
||||||
isOpen={isCreateModalOpen}
|
|
||||||
onClose={() => setIsCreateModalOpen(false)}
|
|
||||||
onSuccess={refreshTags}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TagForm
|
|
||||||
isOpen={!!editingTag}
|
|
||||||
onClose={() => setEditingTag(null)}
|
|
||||||
onSuccess={refreshTags}
|
|
||||||
tag={editingTag}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { tagsService } from '@/services/tags';
|
|
||||||
import { TagsPageClient } from './TagsPageClient';
|
|
||||||
|
|
||||||
// Force dynamic rendering (no static generation)
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
|
|
||||||
export default async function TagsPage() {
|
|
||||||
// SSR - Récupération des tags côté serveur
|
|
||||||
const initialTags = await tagsService.getTags();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TagsPageClient initialTags={initialTags} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user