Files
towercontrol/components/settings/GeneralSettingsPageClient.tsx
Julien Froidefond c4d8bacd97 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.
2025-09-19 13:34:15 +02:00

362 lines
17 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState, useMemo } from 'react';
import { UserPreferences, Tag } from '@/lib/types';
import { useTags } from '@/hooks/useTags';
import { Header } from '@/components/ui/Header';
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 Link from 'next/link';
interface GeneralSettingsPageClientProps {
initialPreferences: UserPreferences;
initialTags: Tag[];
}
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 (
<UserPreferencesProvider initialPreferences={initialPreferences}>
<div className="min-h-screen bg-[var(--background)]">
<Header
title="TowerControl"
subtitle="Paramètres généraux"
/>
<div className="container mx-auto px-4 py-4">
<div className="max-w-4xl mx-auto">
{/* Breadcrumb */}
<div className="mb-4 text-sm">
<Link href="/settings" className="text-[var(--muted-foreground)] hover:text-[var(--primary)]">
Paramètres
</Link>
<span className="mx-2 text-[var(--muted-foreground)]">/</span>
<span className="text-[var(--foreground)]">Général</span>
</div>
{/* Page Header */}
<div className="mb-6">
<h1 className="text-2xl font-mono font-bold text-[var(--foreground)] mb-2">
Paramètres généraux
</h1>
<p className="text-[var(--muted-foreground)]">
Configuration des préférences de l&apos;interface et du comportement général
</p>
</div>
<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 */}
<Card>
<CardContent className="p-4">
<div className="p-4 bg-[var(--warning)]/10 border border-[var(--warning)]/20 rounded">
<p className="text-sm text-[var(--warning)] font-medium mb-2">
🚧 Interface de configuration en développement
</p>
<p className="text-xs text-[var(--muted-foreground)]">
Les contrôles interactifs pour modifier les autres préférences seront disponibles dans une prochaine version.
Pour l&apos;instant, les préférences sont modifiables via les boutons de l&apos;interface principale.
</p>
</div>
</CardContent>
</Card>
</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>
);
}