chore: refactor project structure and clean up unused components
- Updated `TODO.md` to reflect new testing tasks and final structure expectations. - Simplified TypeScript path mappings in `tsconfig.json` for better clarity. - Revised business logic separation rules in `.cursor/rules` to align with new directory structure. - Deleted unused client components and services to streamline the codebase. - Adjusted import paths in scripts to match the new structure.
This commit is contained in:
263
src/components/settings/AdvancedSettingsPageClient.tsx
Normal file
263
src/components/settings/AdvancedSettingsPageClient.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { UserPreferences } from '@/lib/types';
|
||||
import { Header } from '@/components/ui/Header';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { UserPreferencesProvider } from '@/contexts/UserPreferencesContext';
|
||||
import { backupClient, BackupListResponse } from '@/clients/backup-client';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface DatabaseStats {
|
||||
taskCount: number;
|
||||
tagCount: number;
|
||||
completionRate: number;
|
||||
}
|
||||
|
||||
interface AdvancedSettingsPageClientProps {
|
||||
initialPreferences: UserPreferences;
|
||||
initialDbStats: DatabaseStats;
|
||||
initialBackupData: BackupListResponse;
|
||||
}
|
||||
|
||||
export function AdvancedSettingsPageClient({
|
||||
initialPreferences,
|
||||
initialDbStats,
|
||||
initialBackupData
|
||||
}: AdvancedSettingsPageClientProps) {
|
||||
const [backupData, setBackupData] = useState<BackupListResponse>(initialBackupData);
|
||||
const [dbStats] = useState<DatabaseStats>(initialDbStats);
|
||||
const [isCreatingBackup, setIsCreatingBackup] = useState(false);
|
||||
const [isVerifying, setIsVerifying] = useState(false);
|
||||
|
||||
const reloadBackupData = async () => {
|
||||
try {
|
||||
const data = await backupClient.listBackups();
|
||||
setBackupData(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to reload backup data:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateBackup = async () => {
|
||||
setIsCreatingBackup(true);
|
||||
try {
|
||||
const result = await backupClient.createBackup();
|
||||
if (result === null) {
|
||||
alert('⏭️ Sauvegarde sautée : aucun changement détecté');
|
||||
} else {
|
||||
alert('✅ Sauvegarde créée avec succès');
|
||||
}
|
||||
await reloadBackupData();
|
||||
} catch (error) {
|
||||
console.error('Failed to create backup:', error);
|
||||
alert('❌ Erreur lors de la création de la sauvegarde');
|
||||
} finally {
|
||||
setIsCreatingBackup(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerifyDatabase = async () => {
|
||||
setIsVerifying(true);
|
||||
try {
|
||||
await backupClient.verifyDatabase();
|
||||
alert('✅ Base de données vérifiée avec succès');
|
||||
} catch (error) {
|
||||
console.error('Database verification failed:', error);
|
||||
alert('❌ Erreur lors de la vérification de la base');
|
||||
} finally {
|
||||
setIsVerifying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||
};
|
||||
|
||||
const formatTimeAgo = (date: Date): string => {
|
||||
// Format fixe pour éviter les erreurs d'hydratation
|
||||
const d = new Date(date);
|
||||
return d.toLocaleDateString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
};
|
||||
|
||||
const getNextBackupTime = (): string => {
|
||||
if (!backupData.scheduler.nextBackup) return 'Non planifiée';
|
||||
|
||||
const nextBackup = new Date(backupData.scheduler.nextBackup);
|
||||
const now = new Date();
|
||||
const diffMs = nextBackup.getTime() - now.getTime();
|
||||
const diffMins = Math.floor(diffMs / (1000 * 60));
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
|
||||
if (diffMins < 60) {
|
||||
return `dans ${diffMins}min`;
|
||||
} else {
|
||||
return `dans ${diffHours}h ${diffMins % 60}min`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<UserPreferencesProvider initialPreferences={initialPreferences}>
|
||||
<div className="min-h-screen bg-[var(--background)]">
|
||||
<Header
|
||||
title="TowerControl"
|
||||
subtitle="Paramètres avancés"
|
||||
/>
|
||||
|
||||
<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)]">Avancé</span>
|
||||
</div>
|
||||
|
||||
{/* Page Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-mono font-bold text-[var(--foreground)] mb-2">
|
||||
🛠️ Paramètres avancés
|
||||
</h1>
|
||||
<p className="text-[var(--muted-foreground)]">
|
||||
Configuration système, sauvegarde et outils de développement
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Sauvegarde et données */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold">💾 Sauvegarde et données</h2>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Gestion des sauvegardes automatiques et manuelles
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="font-medium">Sauvegarde automatique</h3>
|
||||
<span className={`h-2 w-2 rounded-full ${
|
||||
backupData.scheduler.isRunning ? 'bg-green-500' : 'bg-red-500'
|
||||
}`}></span>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--muted-foreground)] mb-2">
|
||||
{backupData.scheduler.isEnabled
|
||||
? `Sauvegarde ${backupData.scheduler.interval === 'hourly' ? 'toutes les heures' :
|
||||
backupData.scheduler.interval === 'daily' ? 'quotidienne' : 'hebdomadaire'}`
|
||||
: 'Sauvegarde automatique désactivée'
|
||||
}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
{backupData.scheduler.isRunning
|
||||
? `Prochaine sauvegarde: ${getNextBackupTime()}`
|
||||
: 'Planificateur arrêté'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
||||
<h3 className="font-medium mb-2">Sauvegardes disponibles</h3>
|
||||
<p className="text-sm text-[var(--muted-foreground)] mb-2">
|
||||
{backupData.backups.length} sauvegarde{backupData.backups.length > 1 ? 's' : ''} conservée{backupData.backups.length > 1 ? 's' : ''}
|
||||
</p>
|
||||
{backupData.backups.length > 0 ? (
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
Dernière: {formatTimeAgo(backupData.backups[0].createdAt)}
|
||||
({formatFileSize(backupData.backups[0].size)})
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
Aucune sauvegarde disponible
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button
|
||||
onClick={handleCreateBackup}
|
||||
disabled={isCreatingBackup}
|
||||
className="px-3 py-1.5 bg-[var(--primary)] text-[var(--primary-foreground)] rounded text-sm font-medium"
|
||||
>
|
||||
{isCreatingBackup ? 'Création...' : 'Créer une sauvegarde'}
|
||||
</Button>
|
||||
<Link href="/settings/backup">
|
||||
<Button className="px-3 py-1.5 bg-[var(--card)] text-[var(--foreground)] border border-[var(--border)] rounded text-sm font-medium">
|
||||
Gérer les sauvegardes
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
onClick={handleVerifyDatabase}
|
||||
disabled={isVerifying}
|
||||
className="px-3 py-1.5 bg-[var(--card)] text-[var(--foreground)] border border-[var(--border)] rounded text-sm font-medium"
|
||||
>
|
||||
{isVerifying ? 'Vérification...' : 'Vérifier DB'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Base de données */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold">🗄️ Base de données</h2>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Informations et maintenance de la base de données
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
||||
<h3 className="font-medium mb-1">Tâches</h3>
|
||||
<p className="text-2xl font-bold text-[var(--primary)]">
|
||||
{dbStats.taskCount}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--muted-foreground)]">entrées</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
||||
<h3 className="font-medium mb-1">Tags</h3>
|
||||
<p className="text-2xl font-bold text-[var(--primary)]">
|
||||
{dbStats.tagCount}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--muted-foreground)]">entrées</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
||||
<h3 className="font-medium mb-1">Taux complétion</h3>
|
||||
<p className="text-2xl font-bold text-[var(--primary)]">
|
||||
{dbStats.completionRate}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--muted-foreground)]">%</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UserPreferencesProvider>
|
||||
);
|
||||
}
|
||||
709
src/components/settings/BackupSettingsPageClient.tsx
Normal file
709
src/components/settings/BackupSettingsPageClient.tsx
Normal file
@@ -0,0 +1,709 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { backupClient, BackupListResponse } from '@/clients/backup-client';
|
||||
import { BackupConfig } from '@/services/backup';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Header } from '@/components/ui/Header';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface BackupSettingsPageClientProps {
|
||||
initialData?: BackupListResponse;
|
||||
}
|
||||
|
||||
export default function BackupSettingsPageClient({ initialData }: BackupSettingsPageClientProps) {
|
||||
const [data, setData] = useState<BackupListResponse | null>(initialData || null);
|
||||
const [isLoading, setIsLoading] = useState(!initialData);
|
||||
const [isCreatingBackup, setIsCreatingBackup] = useState(false);
|
||||
const [isVerifying, setIsVerifying] = useState(false);
|
||||
const [showRestoreConfirm, setShowRestoreConfirm] = useState<string | null>(null);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState<string | null>(null);
|
||||
const [config, setConfig] = useState<BackupConfig | null>(initialData?.config || null);
|
||||
const [isSavingConfig, setIsSavingConfig] = useState(false);
|
||||
const [logs, setLogs] = useState<string[]>([]);
|
||||
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
|
||||
const [showLogs, setShowLogs] = useState(false);
|
||||
const [messages, setMessages] = useState<{[key: string]: {type: 'success' | 'error', text: string} | null}>({
|
||||
verify: null,
|
||||
config: null,
|
||||
restore: null,
|
||||
delete: null,
|
||||
backup: null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialData) {
|
||||
loadData();
|
||||
}
|
||||
}, [initialData]);
|
||||
|
||||
// Helper pour définir un message contextuel
|
||||
const setMessage = (key: string, message: {type: 'success' | 'error', text: string} | null) => {
|
||||
setMessages(prev => ({ ...prev, [key]: message }));
|
||||
|
||||
// Auto-dismiss après 3 secondes
|
||||
if (message) {
|
||||
setTimeout(() => {
|
||||
setMessages(prev => ({ ...prev, [key]: null }));
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
console.log('🔄 Loading backup data...');
|
||||
const response = await backupClient.listBackups();
|
||||
console.log('✅ Backup data loaded:', response);
|
||||
setData(response);
|
||||
setConfig(response.config);
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to load backup data:', error);
|
||||
// Afficher l'erreur spécifique à l'utilisateur
|
||||
if (error instanceof Error) {
|
||||
console.error('Error details:', error.message);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateBackup = async (force: boolean = false) => {
|
||||
setIsCreatingBackup(true);
|
||||
try {
|
||||
const result = await backupClient.createBackup(force);
|
||||
|
||||
if (result === null) {
|
||||
setMessage('backup', {
|
||||
type: 'success',
|
||||
text: 'Sauvegarde sautée : aucun changement détecté. Utilisez "Forcer" pour créer malgré tout.'
|
||||
});
|
||||
} else {
|
||||
setMessage('backup', {
|
||||
type: 'success',
|
||||
text: `Sauvegarde créée : ${result.filename}`
|
||||
});
|
||||
}
|
||||
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
console.error('Failed to create backup:', error);
|
||||
setMessage('backup', {
|
||||
type: 'error',
|
||||
text: 'Erreur lors de la création de la sauvegarde'
|
||||
});
|
||||
} finally {
|
||||
setIsCreatingBackup(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerifyDatabase = async () => {
|
||||
setIsVerifying(true);
|
||||
setMessage('verify', null);
|
||||
try {
|
||||
await backupClient.verifyDatabase();
|
||||
setMessage('verify', {type: 'success', text: 'Intégrité vérifiée'});
|
||||
} catch (error) {
|
||||
console.error('Database verification failed:', error);
|
||||
setMessage('verify', {type: 'error', text: 'Vérification échouée'});
|
||||
} finally {
|
||||
setIsVerifying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteBackup = async (filename: string) => {
|
||||
try {
|
||||
await backupClient.deleteBackup(filename);
|
||||
setShowDeleteConfirm(null);
|
||||
setMessage('restore', {type: 'success', text: `Sauvegarde ${filename} supprimée`});
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete backup:', error);
|
||||
setMessage('restore', {type: 'error', text: 'Suppression échouée'});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestoreBackup = async (filename: string) => {
|
||||
try {
|
||||
await backupClient.restoreBackup(filename);
|
||||
setShowRestoreConfirm(null);
|
||||
setMessage('restore', {type: 'success', text: 'Restauration réussie'});
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
console.error('Failed to restore backup:', error);
|
||||
setMessage('restore', {type: 'error', text: 'Restauration échouée'});
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleScheduler = async () => {
|
||||
if (!data) return;
|
||||
|
||||
try {
|
||||
await backupClient.toggleScheduler(!data.scheduler.isRunning);
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle scheduler:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveConfig = async () => {
|
||||
if (!config) return;
|
||||
|
||||
setIsSavingConfig(true);
|
||||
setMessage('config', null);
|
||||
try {
|
||||
await backupClient.updateConfig(config);
|
||||
await loadData();
|
||||
setMessage('config', {type: 'success', text: 'Configuration sauvegardée'});
|
||||
} catch (error) {
|
||||
console.error('Failed to save config:', error);
|
||||
setMessage('config', {type: 'error', text: 'Sauvegarde échouée'});
|
||||
} finally {
|
||||
setIsSavingConfig(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadLogs = async () => {
|
||||
setIsLoadingLogs(true);
|
||||
try {
|
||||
const backupLogs = await backupClient.getBackupLogs(50);
|
||||
setLogs(backupLogs);
|
||||
} catch (error) {
|
||||
console.error('Failed to load backup logs:', error);
|
||||
setLogs([]);
|
||||
} finally {
|
||||
setIsLoadingLogs(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||
};
|
||||
|
||||
const formatDate = (date: string | Date): string => {
|
||||
// Format cohérent serveur/client pour éviter les erreurs d'hydratation
|
||||
const d = new Date(date);
|
||||
return d.toLocaleDateString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="p-6">Loading backup settings...</div>;
|
||||
}
|
||||
|
||||
if (!data || !config) {
|
||||
return <div className="p-6">Failed to load backup settings</div>;
|
||||
}
|
||||
|
||||
const getNextBackupTime = (): string => {
|
||||
if (!data?.scheduler.nextBackup) return 'Non planifiée';
|
||||
|
||||
const nextBackup = new Date(data.scheduler.nextBackup);
|
||||
const now = new Date();
|
||||
const diffMs = nextBackup.getTime() - now.getTime();
|
||||
const diffMins = Math.floor(diffMs / (1000 * 60));
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
|
||||
if (diffMins < 60) {
|
||||
return `dans ${diffMins}min`;
|
||||
} else {
|
||||
return `dans ${diffHours}h ${diffMins % 60}min`;
|
||||
}
|
||||
};
|
||||
|
||||
// Composant pour les messages inline
|
||||
const InlineMessage = ({ messageKey }: { messageKey: string }) => {
|
||||
const message = messages[messageKey];
|
||||
if (!message) return null;
|
||||
|
||||
return (
|
||||
<div className={`text-xs mt-2 px-2 py-1 rounded transition-all inline-block ${
|
||||
message.type === 'success'
|
||||
? 'text-green-700 dark:text-green-300 bg-green-50 dark:bg-green-950/20 border border-green-200 dark:border-green-800/20'
|
||||
: 'text-red-700 dark:text-red-300 bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-800/20'
|
||||
}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--background)]">
|
||||
<Header
|
||||
title="TowerControl"
|
||||
subtitle="Gestion des sauvegardes"
|
||||
/>
|
||||
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<div className="max-w-6xl 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>
|
||||
<Link href="/settings/advanced" className="text-[var(--muted-foreground)] hover:text-[var(--primary)]">
|
||||
Avancé
|
||||
</Link>
|
||||
<span className="mx-2 text-[var(--muted-foreground)]">/</span>
|
||||
<span className="text-[var(--foreground)]">Sauvegardes</span>
|
||||
</div>
|
||||
|
||||
{/* Page Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-mono font-bold text-[var(--foreground)] mb-2">
|
||||
💾 Gestion des sauvegardes
|
||||
</h1>
|
||||
<p className="text-[var(--muted-foreground)]">
|
||||
Configuration et gestion des sauvegardes automatiques de votre base de données
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Layout en 2 colonnes pour optimiser l'espace */}
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||
|
||||
{/* Colonne principale: Configuration */}
|
||||
<div className="xl:col-span-2 space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<span className="text-blue-600">⚙️</span>
|
||||
Configuration automatique
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Paramètres des sauvegardes programmées
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-[var(--foreground)]">
|
||||
Sauvegardes automatiques
|
||||
</label>
|
||||
<select
|
||||
value={config.enabled ? 'enabled' : 'disabled'}
|
||||
onChange={(e) => setConfig({
|
||||
...config,
|
||||
enabled: e.target.value === 'enabled'
|
||||
})}
|
||||
className="w-full px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-md text-[var(--foreground)] focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent"
|
||||
>
|
||||
<option value="enabled">Activées</option>
|
||||
<option value="disabled">Désactivées</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-[var(--foreground)]">
|
||||
Fréquence
|
||||
</label>
|
||||
<select
|
||||
value={config.interval}
|
||||
onChange={(e) => setConfig({
|
||||
...config,
|
||||
interval: e.target.value as BackupConfig['interval']
|
||||
})}
|
||||
className="w-full px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-md text-[var(--foreground)] focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent"
|
||||
>
|
||||
<option value="hourly">Toutes les heures</option>
|
||||
<option value="daily">Quotidienne</option>
|
||||
<option value="weekly">Hebdomadaire</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-[var(--foreground)]">
|
||||
Rétention (nombre max)
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="50"
|
||||
value={config.maxBackups}
|
||||
onChange={(e) => setConfig({
|
||||
...config,
|
||||
maxBackups: parseInt(e.target.value) || 7
|
||||
})}
|
||||
className="bg-[var(--background)] border-[var(--border)] text-[var(--foreground)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-[var(--foreground)]">
|
||||
Compression
|
||||
</label>
|
||||
<select
|
||||
value={config.compression ? 'enabled' : 'disabled'}
|
||||
onChange={(e) => setConfig({
|
||||
...config,
|
||||
compression: e.target.value === 'enabled'
|
||||
})}
|
||||
className="w-full px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-md text-[var(--foreground)] focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent"
|
||||
>
|
||||
<option value="enabled">Activée (gzip)</option>
|
||||
<option value="disabled">Désactivée</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-[var(--border)]">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
onClick={handleSaveConfig}
|
||||
disabled={isSavingConfig}
|
||||
className="bg-[var(--primary)] hover:bg-[var(--primary)]/90 text-[var(--primary-foreground)]"
|
||||
>
|
||||
{isSavingConfig ? 'Sauvegarde...' : 'Sauvegarder'}
|
||||
</Button>
|
||||
<InlineMessage messageKey="config" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Actions manuelles */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<span className="text-green-600">🚀</span>
|
||||
Actions manuelles
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Créer une sauvegarde ou vérifier l'intégrité de la base
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => handleCreateBackup(false)}
|
||||
disabled={isCreatingBackup}
|
||||
className="bg-[var(--primary)] hover:bg-[var(--primary)]/90 text-[var(--primary-foreground)]"
|
||||
>
|
||||
{isCreatingBackup ? 'Création...' : 'Créer sauvegarde'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleCreateBackup(true)}
|
||||
disabled={isCreatingBackup}
|
||||
className="bg-orange-600 hover:bg-orange-700 text-white"
|
||||
>
|
||||
{isCreatingBackup ? 'Création...' : 'Forcer'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
<strong>Créer :</strong> Vérifie les changements • <strong>Forcer :</strong> Crée toujours
|
||||
</div>
|
||||
<InlineMessage messageKey="backup" />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleVerifyDatabase}
|
||||
disabled={isVerifying}
|
||||
className="bg-[var(--card)] text-[var(--foreground)] border border-[var(--border)] hover:bg-[var(--muted)]"
|
||||
>
|
||||
{isVerifying ? 'Vérification...' : 'Vérifier l\'intégrité'}
|
||||
</Button>
|
||||
<InlineMessage messageKey="verify" />
|
||||
|
||||
<Button
|
||||
onClick={handleToggleScheduler}
|
||||
className={data.scheduler.isRunning
|
||||
? 'bg-[var(--destructive)] hover:bg-[var(--destructive)]/90 text-[var(--destructive-foreground)]'
|
||||
: 'bg-[var(--primary)] hover:bg-[var(--primary)]/90 text-[var(--primary-foreground)]'
|
||||
}
|
||||
>
|
||||
{data.scheduler.isRunning ? 'Arrêter le planificateur' : 'Démarrer le planificateur'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Colonne latérale: Statut et historique */}
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* Statut du système */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">📊 Statut système</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{/* Status planificateur */}
|
||||
<div className="p-3 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`h-2 w-2 rounded-full ${
|
||||
data.scheduler.isRunning ? 'bg-green-500' : 'bg-red-500'
|
||||
}`}></span>
|
||||
<span className="text-sm font-medium">
|
||||
{data.scheduler.isRunning ? 'Actif' : 'Arrêté'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
Prochaine: {getNextBackupTime()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Statistiques */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
||||
<p className="text-lg font-bold text-[var(--primary)]">{data.backups.length}</p>
|
||||
<p className="text-xs text-[var(--muted-foreground)]">sauvegardes</p>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
||||
<p className="text-lg font-bold text-[var(--primary)]">{config.maxBackups}</p>
|
||||
<p className="text-xs text-[var(--muted-foreground)]">max conservées</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chemin */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-[var(--muted-foreground)] mb-1">Stockage</p>
|
||||
<code className="text-xs bg-[var(--muted)] text-[var(--muted-foreground)] px-2 py-1 rounded border block truncate">
|
||||
{data.config.backupPath}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Historique des sauvegardes */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">📁 Historique</h3>
|
||||
<InlineMessage messageKey="restore" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{data.backups.length === 0 ? (
|
||||
<p className="text-sm text-[var(--muted-foreground)] text-center py-4">
|
||||
Aucune sauvegarde disponible
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{data.backups.slice(0, 10).map((backup) => (
|
||||
<div
|
||||
key={backup.id}
|
||||
className="p-3 bg-[var(--card)] rounded border border-[var(--border)]"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-[var(--foreground)] truncate">
|
||||
{backup.filename.replace('towercontrol_', '').replace('.db.gz', '').replace('.db', '')}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-xs text-[var(--muted-foreground)]">
|
||||
{formatFileSize(backup.size)}
|
||||
</span>
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${
|
||||
backup.type === 'manual'
|
||||
? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300'
|
||||
}`}>
|
||||
{backup.type === 'manual' ? 'Manuel' : 'Auto'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--muted-foreground)] mt-1">
|
||||
{formatDate(backup.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
{process.env.NODE_ENV !== 'production' && (
|
||||
<Button
|
||||
onClick={() => setShowRestoreConfirm(backup.filename)}
|
||||
className="bg-orange-500 hover:bg-orange-600 text-white text-xs px-2 py-1 h-auto"
|
||||
>
|
||||
↻
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={() => setShowDeleteConfirm(backup.filename)}
|
||||
className="bg-red-500 hover:bg-red-600 text-white text-xs px-2 py-1 h-auto"
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{data.backups.length > 10 && (
|
||||
<p className="text-xs text-[var(--muted-foreground)] text-center pt-2">
|
||||
... et {data.backups.length - 10} autres
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section des logs */}
|
||||
<div className="mt-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<span className="text-blue-600">📋</span>
|
||||
Logs des sauvegardes
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Historique des opérations de sauvegarde
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (!showLogs) {
|
||||
loadLogs();
|
||||
}
|
||||
setShowLogs(!showLogs);
|
||||
}}
|
||||
className="bg-[var(--card)] text-[var(--foreground)] border border-[var(--border)] hover:bg-[var(--muted)]"
|
||||
>
|
||||
{showLogs ? 'Masquer' : 'Afficher'} les logs
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{showLogs && (
|
||||
<CardContent>
|
||||
{isLoadingLogs ? (
|
||||
<div className="text-center py-4">
|
||||
<p className="text-sm text-[var(--muted-foreground)]">Chargement des logs...</p>
|
||||
</div>
|
||||
) : logs.length === 0 ? (
|
||||
<div className="text-center py-4">
|
||||
<p className="text-sm text-[var(--muted-foreground)]">Aucun log disponible</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1 max-h-64 overflow-y-auto">
|
||||
{logs.map((log, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="text-xs font-mono p-2 bg-[var(--muted)] rounded border"
|
||||
>
|
||||
{log}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{showLogs && (
|
||||
<div className="mt-3 pt-3 border-t border-[var(--border)]">
|
||||
<Button
|
||||
onClick={loadLogs}
|
||||
disabled={isLoadingLogs}
|
||||
className="text-xs bg-[var(--card)] text-[var(--foreground)] border border-[var(--border)] hover:bg-[var(--muted)]"
|
||||
>
|
||||
{isLoadingLogs ? 'Actualisation...' : 'Actualiser'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal de confirmation de restauration */}
|
||||
{showRestoreConfirm && (
|
||||
<Modal
|
||||
isOpen={true}
|
||||
onClose={() => setShowRestoreConfirm(null)}
|
||||
title="Confirmer la restauration"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p>
|
||||
⚠️ <strong>Attention :</strong> Cette action va remplacer complètement
|
||||
la base de données actuelle par la sauvegarde sélectionnée.
|
||||
</p>
|
||||
<p>
|
||||
Une sauvegarde de la base actuelle sera créée automatiquement
|
||||
avant la restauration.
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
Fichier à restaurer: <code>{showRestoreConfirm}</code>
|
||||
</p>
|
||||
|
||||
<div className="flex justify-end space-x-3">
|
||||
<Button
|
||||
onClick={() => setShowRestoreConfirm(null)}
|
||||
className="bg-[var(--card)] text-[var(--foreground)] border border-[var(--border)]"
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleRestoreBackup(showRestoreConfirm)}
|
||||
className="bg-[var(--warning)] hover:bg-[var(--warning)]/90 text-[var(--warning-foreground)]"
|
||||
>
|
||||
Confirmer la restauration
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* Modal de confirmation de suppression */}
|
||||
{showDeleteConfirm && (
|
||||
<Modal
|
||||
isOpen={true}
|
||||
onClose={() => setShowDeleteConfirm(null)}
|
||||
title="Confirmer la suppression"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p>
|
||||
Êtes-vous sûr de vouloir supprimer cette sauvegarde ?
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
Fichier: <code>{showDeleteConfirm}</code>
|
||||
</p>
|
||||
<p className="text-red-600 text-sm">
|
||||
Cette action est irréversible.
|
||||
</p>
|
||||
|
||||
<div className="flex justify-end space-x-3">
|
||||
<Button
|
||||
onClick={() => setShowDeleteConfirm(null)}
|
||||
className="bg-[var(--card)] text-[var(--foreground)] border border-[var(--border)]"
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleDeleteBackup(showDeleteConfirm)}
|
||||
className="bg-[var(--destructive)] hover:bg-[var(--destructive)]/90 text-[var(--destructive-foreground)]"
|
||||
>
|
||||
Supprimer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
361
src/components/settings/GeneralSettingsPageClient.tsx
Normal file
361
src/components/settings/GeneralSettingsPageClient.tsx
Normal file
@@ -0,0 +1,361 @@
|
||||
'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'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'instant, les préférences sont modifiables via les boutons de l'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>
|
||||
);
|
||||
}
|
||||
172
src/components/settings/IntegrationsSettingsPageClient.tsx
Normal file
172
src/components/settings/IntegrationsSettingsPageClient.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
'use client';
|
||||
|
||||
import { UserPreferences, JiraConfig } from '@/lib/types';
|
||||
import { Header } from '@/components/ui/Header';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { JiraConfigForm } from '@/components/settings/JiraConfigForm';
|
||||
import { JiraSync } from '@/components/jira/JiraSync';
|
||||
import { JiraLogs } from '@/components/jira/JiraLogs';
|
||||
import { UserPreferencesProvider } from '@/contexts/UserPreferencesContext';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface IntegrationsSettingsPageClientProps {
|
||||
initialPreferences: UserPreferences;
|
||||
initialJiraConfig: JiraConfig;
|
||||
}
|
||||
|
||||
export function IntegrationsSettingsPageClient({
|
||||
initialPreferences,
|
||||
initialJiraConfig
|
||||
}: IntegrationsSettingsPageClientProps) {
|
||||
return (
|
||||
<UserPreferencesProvider initialPreferences={initialPreferences}>
|
||||
<div className="min-h-screen bg-[var(--background)]">
|
||||
<Header
|
||||
title="TowerControl"
|
||||
subtitle="Intégrations externes"
|
||||
/>
|
||||
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<div className="max-w-6xl 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)]">Intégrations</span>
|
||||
</div>
|
||||
|
||||
{/* Page Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-mono font-bold text-[var(--foreground)] mb-2">
|
||||
🔌 Intégrations externes
|
||||
</h1>
|
||||
<p className="text-[var(--muted-foreground)]">
|
||||
Configuration des intégrations avec les outils externes
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Layout en 2 colonnes pour optimiser l'espace */}
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||
|
||||
{/* Colonne principale: Configuration Jira */}
|
||||
<div className="xl:col-span-2 space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<span className="text-blue-600">🏢</span>
|
||||
Jira Cloud
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Synchronisation automatique des tickets Jira vers TowerControl
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<JiraConfigForm />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Futures intégrations */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-xl font-semibold">Autres intégrations</h2>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Intégrations prévues pour les prochaines versions
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-lg">📧</span>
|
||||
<h3 className="font-medium">Slack/Teams</h3>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Notifications et commandes via chat
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-lg">🐙</span>
|
||||
<h3 className="font-medium">GitHub/GitLab</h3>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Synchronisation des issues et PR
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-lg">📊</span>
|
||||
<h3 className="font-medium">Calendriers</h3>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Google Calendar, Outlook, etc.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-lg">⏱️</span>
|
||||
<h3 className="font-medium">Time tracking</h3>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Toggl, RescueTime, etc.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Colonne latérale: Actions et Logs Jira */}
|
||||
<div className="space-y-4">
|
||||
{initialJiraConfig?.enabled && (
|
||||
<>
|
||||
{/* Dashboard Analytics */}
|
||||
{initialJiraConfig.projectKey && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-sm font-semibold">📊 Analytics d'équipe</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
Surveillance du projet {initialJiraConfig.projectKey}
|
||||
</p>
|
||||
<Link
|
||||
href="/jira-dashboard"
|
||||
className="inline-flex items-center justify-center w-full px-3 py-2 text-sm font-medium bg-[var(--primary)] text-[var(--primary-foreground)] rounded-lg hover:bg-[var(--primary)]/90 transition-colors"
|
||||
>
|
||||
Voir le Dashboard
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<JiraSync />
|
||||
<JiraLogs />
|
||||
</>
|
||||
)}
|
||||
|
||||
{!initialJiraConfig?.enabled && (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-center py-6">
|
||||
<span className="text-4xl mb-4 block">🔧</span>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Configurez Jira pour accéder aux outils de synchronisation
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UserPreferencesProvider>
|
||||
);
|
||||
}
|
||||
420
src/components/settings/JiraConfigForm.tsx
Normal file
420
src/components/settings/JiraConfigForm.tsx
Normal file
@@ -0,0 +1,420 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { useJiraConfig } from '@/hooks/useJiraConfig';
|
||||
import { jiraConfigClient } from '@/clients/jira-config-client';
|
||||
|
||||
export function JiraConfigForm() {
|
||||
const { config, isLoading: configLoading, saveConfig, deleteConfig } = useJiraConfig();
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
baseUrl: '',
|
||||
email: '',
|
||||
apiToken: '',
|
||||
projectKey: '',
|
||||
ignoredProjects: [] as string[]
|
||||
});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const [validationResult, setValidationResult] = useState<{ type: 'success' | 'error', text: string } | null>(null);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
|
||||
// Charger les données existantes dans le formulaire
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
setFormData({
|
||||
baseUrl: config.baseUrl || '',
|
||||
email: config.email || '',
|
||||
apiToken: config.apiToken || '',
|
||||
projectKey: config.projectKey || '',
|
||||
ignoredProjects: config.ignoredProjects || []
|
||||
});
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
// Afficher le formulaire par défaut si Jira n'est pas configuré
|
||||
useEffect(() => {
|
||||
const isConfigured = config?.enabled && (config?.baseUrl || config?.email);
|
||||
if (!configLoading && !isConfigured) {
|
||||
setShowForm(true);
|
||||
}
|
||||
}, [config, configLoading]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const result = await saveConfig(formData);
|
||||
|
||||
if (result.success) {
|
||||
setMessage({
|
||||
type: 'success',
|
||||
text: result.message
|
||||
});
|
||||
// Masquer le formulaire après une sauvegarde réussie
|
||||
setShowForm(false);
|
||||
} else {
|
||||
setMessage({
|
||||
type: 'error',
|
||||
text: result.message
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({
|
||||
type: 'error',
|
||||
text: error instanceof Error ? error.message : 'Erreur lors de la sauvegarde de la configuration'
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm('Êtes-vous sûr de vouloir supprimer la configuration Jira ?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const result = await deleteConfig();
|
||||
|
||||
if (result.success) {
|
||||
setFormData({
|
||||
baseUrl: '',
|
||||
email: '',
|
||||
apiToken: '',
|
||||
projectKey: '',
|
||||
ignoredProjects: []
|
||||
});
|
||||
setMessage({
|
||||
type: 'success',
|
||||
text: result.message
|
||||
});
|
||||
} else {
|
||||
setMessage({
|
||||
type: 'error',
|
||||
text: result.message
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({
|
||||
type: 'error',
|
||||
text: error instanceof Error ? error.message : 'Erreur lors de la suppression de la configuration'
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleValidateProject = async () => {
|
||||
if (!formData.projectKey.trim()) {
|
||||
setValidationResult({
|
||||
type: 'error',
|
||||
text: 'Veuillez saisir une clé de projet'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsValidating(true);
|
||||
setValidationResult(null);
|
||||
|
||||
try {
|
||||
const result = await jiraConfigClient.validateProject(formData.projectKey);
|
||||
|
||||
if (result.success && result.exists) {
|
||||
setValidationResult({
|
||||
type: 'success',
|
||||
text: `✓ Projet trouvé : ${result.projectName}`
|
||||
});
|
||||
} else {
|
||||
setValidationResult({
|
||||
type: 'error',
|
||||
text: result.error || result.message
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setValidationResult({
|
||||
type: 'error',
|
||||
text: error instanceof Error ? error.message : 'Erreur lors de la validation'
|
||||
});
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isJiraConfigured = config?.enabled && (config?.baseUrl || config?.email);
|
||||
const isLoading = configLoading || isSubmitting;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Statut actuel */}
|
||||
<div className="flex items-center justify-between p-4 bg-[var(--card)] rounded border">
|
||||
<div>
|
||||
<h3 className="font-medium">Statut de l'intégration</h3>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
{isJiraConfigured
|
||||
? 'Jira est configuré et prêt à être utilisé'
|
||||
: 'Jira n\'est pas configuré'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant={isJiraConfigured ? 'success' : 'danger'}>
|
||||
{isJiraConfigured ? '✓ Configuré' : '✗ Non configuré'}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setShowForm(!showForm)}
|
||||
>
|
||||
{showForm ? 'Masquer' : (isJiraConfigured ? 'Modifier' : 'Configurer')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isJiraConfigured && (
|
||||
<div className="p-4 bg-[var(--card)] rounded border">
|
||||
<h3 className="font-medium mb-2">Configuration actuelle</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div>
|
||||
<span className="text-[var(--muted-foreground)]">URL de base:</span>{' '}
|
||||
<code className="bg-[var(--background)] px-2 py-1 rounded text-xs">
|
||||
{config?.baseUrl || 'Non définie'}
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[var(--muted-foreground)]">Email:</span>{' '}
|
||||
<code className="bg-[var(--background)] px-2 py-1 rounded text-xs">
|
||||
{config?.email || 'Non défini'}
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[var(--muted-foreground)]">Token API:</span>{' '}
|
||||
<code className="bg-[var(--background)] px-2 py-1 rounded text-xs">
|
||||
{config?.apiToken ? '••••••••' : 'Non défini'}
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[var(--muted-foreground)]">Projet surveillé:</span>{' '}
|
||||
<code className="bg-[var(--background)] px-2 py-1 rounded text-xs">
|
||||
{config?.projectKey || 'Non défini'}
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[var(--muted-foreground)]">Projets ignorés:</span>{' '}
|
||||
{config?.ignoredProjects && config.ignoredProjects.length > 0 ? (
|
||||
<div className="mt-1 space-x-1">
|
||||
{config.ignoredProjects.map(project => (
|
||||
<code key={project} className="bg-[var(--background)] px-2 py-1 rounded text-xs">
|
||||
{project}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs">Aucun</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Formulaire de configuration */}
|
||||
{showForm && (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
URL de base Jira Cloud
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={formData.baseUrl}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, baseUrl: e.target.value }))}
|
||||
placeholder="https://votre-domaine.atlassian.net"
|
||||
className="w-full px-3 py-2 border border-[var(--border)] rounded bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-[var(--muted-foreground)] mt-1">
|
||||
L'URL de votre instance Jira Cloud (ex: https://monentreprise.atlassian.net)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Email Jira
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, email: e.target.value }))}
|
||||
placeholder="votre-email@exemple.com"
|
||||
className="w-full px-3 py-2 border border-[var(--border)] rounded bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-[var(--muted-foreground)] mt-1">
|
||||
L'email de votre compte Jira
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Token API Jira
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.apiToken}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, apiToken: e.target.value }))}
|
||||
placeholder="Votre token API Jira"
|
||||
className="w-full px-3 py-2 border border-[var(--border)] rounded bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-[var(--muted-foreground)] mt-1">
|
||||
Créez un token API depuis{' '}
|
||||
<a
|
||||
href="https://id.atlassian.com/manage-profile/security/api-tokens"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[var(--primary)] hover:underline"
|
||||
>
|
||||
votre profil Atlassian
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Projet à surveiller (optionnel)
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={formData.projectKey}
|
||||
onChange={(e) => {
|
||||
setFormData(prev => ({ ...prev, projectKey: e.target.value.trim().toUpperCase() }));
|
||||
setValidationResult(null); // Reset validation when input changes
|
||||
}}
|
||||
placeholder="MYTEAM"
|
||||
className="flex-1 px-3 py-2 border border-[var(--border)] rounded bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleValidateProject}
|
||||
disabled={isValidating || !formData.projectKey.trim() || !isJiraConfigured}
|
||||
className="px-4 shrink-0"
|
||||
>
|
||||
{isValidating ? 'Validation...' : 'Valider'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Résultat de la validation */}
|
||||
{validationResult && (
|
||||
<div className={`mt-2 p-2 rounded text-sm ${
|
||||
validationResult.type === 'success'
|
||||
? 'bg-green-50 border border-green-200 text-green-800 dark:bg-green-900/20 dark:border-green-800 dark:text-green-200'
|
||||
: 'bg-red-50 border border-red-200 text-red-800 dark:bg-red-900/20 dark:border-red-800 dark:text-red-200'
|
||||
}`}>
|
||||
{validationResult.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-[var(--muted-foreground)] mt-1">
|
||||
Clé du projet pour les analytics d'équipe (ex: MYTEAM, DEV, PROD).
|
||||
Laissez vide pour désactiver la surveillance d'équipe.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Projets à ignorer (optionnel)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.ignoredProjects.join(', ')}
|
||||
onChange={(e) => {
|
||||
const projects = e.target.value
|
||||
.split(',')
|
||||
.map(p => p.trim().toUpperCase())
|
||||
.filter(p => p.length > 0);
|
||||
setFormData(prev => ({ ...prev, ignoredProjects: projects }));
|
||||
}}
|
||||
placeholder="DEMO, TEST, SANDBOX"
|
||||
className="w-full px-3 py-2 border border-[var(--border)] rounded bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs text-[var(--muted-foreground)] mt-1">
|
||||
Liste des clés de projets à ignorer lors de la synchronisation, séparées par des virgules (ex: DEMO, TEST, SANDBOX).
|
||||
Ces projets ne seront pas synchronisés vers TowerControl.
|
||||
</p>
|
||||
{formData.ignoredProjects.length > 0 && (
|
||||
<div className="mt-2 space-x-1">
|
||||
<span className="text-xs text-[var(--muted-foreground)]">Projets qui seront ignorés:</span>
|
||||
{formData.ignoredProjects.map(project => (
|
||||
<code key={project} className="bg-[var(--muted)] text-[var(--muted-foreground)] px-2 py-1 rounded text-xs">
|
||||
{project}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="flex-1"
|
||||
>
|
||||
{isLoading ? 'Sauvegarde...' : 'Sauvegarder la configuration'}
|
||||
</Button>
|
||||
|
||||
{isJiraConfigured && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleDelete}
|
||||
disabled={isLoading}
|
||||
className="px-6"
|
||||
>
|
||||
Supprimer
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Instructions */}
|
||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
||||
<h3 className="font-medium mb-2">💡 Instructions de configuration</h3>
|
||||
<div className="text-sm text-[var(--muted-foreground)] space-y-2">
|
||||
<p><strong>1. URL de base:</strong> Votre domaine Jira Cloud (ex: https://monentreprise.atlassian.net)</p>
|
||||
<p><strong>2. Email:</strong> L'email de votre compte Jira/Atlassian</p>
|
||||
<p><strong>3. Token API:</strong> Créez un token depuis votre profil Atlassian :</p>
|
||||
<ul className="ml-4 space-y-1 list-disc">
|
||||
<li>Allez sur <a href="https://id.atlassian.com/manage-profile/security/api-tokens" target="_blank" rel="noopener noreferrer" className="text-[var(--primary)] hover:underline">id.atlassian.com</a></li>
|
||||
<li>Cliquez sur "Create API token"</li>
|
||||
<li>Donnez un nom descriptif (ex: "TowerControl")</li>
|
||||
<li>Copiez le token généré</li>
|
||||
</ul>
|
||||
<p className="mt-3 text-xs">
|
||||
<strong>Note:</strong> Ces variables doivent être configurées dans l'environnement du serveur (JIRA_BASE_URL, JIRA_EMAIL, JIRA_API_TOKEN)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{message && (
|
||||
<div className={`p-4 rounded border ${
|
||||
message.type === 'success'
|
||||
? 'bg-green-50 border-green-200 text-green-800 dark:bg-green-900/20 dark:border-green-800 dark:text-green-200'
|
||||
: 'bg-red-50 border-red-200 text-red-800 dark:bg-red-900/20 dark:border-red-800 dark:text-red-200'
|
||||
}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
409
src/components/settings/SettingsIndexPageClient.tsx
Normal file
409
src/components/settings/SettingsIndexPageClient.tsx
Normal file
@@ -0,0 +1,409 @@
|
||||
'use client';
|
||||
|
||||
import { UserPreferences } from '@/lib/types';
|
||||
import { Header } from '@/components/ui/Header';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { UserPreferencesProvider } from '@/contexts/UserPreferencesContext';
|
||||
import Link from 'next/link';
|
||||
import { useState, useEffect, useTransition } from 'react';
|
||||
import { backupClient } from '@/clients/backup-client';
|
||||
import { jiraClient } from '@/clients/jira-client';
|
||||
import { getSystemInfo } from '@/actions/system-info';
|
||||
import { SystemInfo } from '@/services/system-info';
|
||||
|
||||
interface SettingsIndexPageClientProps {
|
||||
initialPreferences: UserPreferences;
|
||||
initialSystemInfo?: SystemInfo;
|
||||
}
|
||||
|
||||
export function SettingsIndexPageClient({ initialPreferences, initialSystemInfo }: SettingsIndexPageClientProps) {
|
||||
// États pour les actions
|
||||
const [isBackupLoading, setIsBackupLoading] = useState(false);
|
||||
const [isJiraTestLoading, setIsJiraTestLoading] = useState(false);
|
||||
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(initialSystemInfo || null);
|
||||
const [messages, setMessages] = useState<{
|
||||
backup?: { type: 'success' | 'error', text: string };
|
||||
jira?: { type: 'success' | 'error', text: string };
|
||||
}>({});
|
||||
|
||||
// useTransition pour le server action
|
||||
const [isSystemInfoLoading, startTransition] = useTransition();
|
||||
|
||||
// Fonction pour recharger les infos système (server action)
|
||||
const loadSystemInfo = () => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await getSystemInfo();
|
||||
if (result.success && result.data) {
|
||||
setSystemInfo(result.data);
|
||||
} else {
|
||||
console.error('Error loading system info:', result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading system info:', error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Fonction pour créer une sauvegarde manuelle
|
||||
const handleCreateBackup = async () => {
|
||||
setIsBackupLoading(true);
|
||||
try {
|
||||
const backup = await backupClient.createBackup();
|
||||
if (backup) {
|
||||
setMessages(prev => ({
|
||||
...prev,
|
||||
backup: { type: 'success', text: `Sauvegarde créée: ${backup.filename}` }
|
||||
}));
|
||||
} else {
|
||||
setMessages(prev => ({
|
||||
...prev,
|
||||
backup: { type: 'success', text: 'Sauvegarde sautée: aucun changement détecté' }
|
||||
}));
|
||||
}
|
||||
|
||||
// Recharger les infos système pour mettre à jour le nombre de sauvegardes
|
||||
loadSystemInfo();
|
||||
} catch {
|
||||
setMessages(prev => ({
|
||||
...prev,
|
||||
backup: { type: 'error', text: 'Erreur lors de la création de la sauvegarde' }
|
||||
}));
|
||||
} finally {
|
||||
setIsBackupLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Fonction pour tester la connexion Jira
|
||||
const handleTestJira = async () => {
|
||||
setIsJiraTestLoading(true);
|
||||
try {
|
||||
const status = await jiraClient.testConnection();
|
||||
setMessages(prev => ({
|
||||
...prev,
|
||||
jira: {
|
||||
type: status.connected ? 'success' : 'error',
|
||||
text: status.connected ? 'Connexion Jira réussie !' : `Erreur: ${status.message || 'Connexion échouée'}`
|
||||
}
|
||||
}));
|
||||
} catch {
|
||||
setMessages(prev => ({
|
||||
...prev,
|
||||
jira: { type: 'error', text: 'Erreur lors du test de connexion Jira' }
|
||||
}));
|
||||
} finally {
|
||||
setIsJiraTestLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-dismiss des messages après 5 secondes
|
||||
useEffect(() => {
|
||||
Object.keys(messages).forEach(key => {
|
||||
if (messages[key as keyof typeof messages]) {
|
||||
const timer = setTimeout(() => {
|
||||
setMessages(prev => ({ ...prev, [key]: undefined }));
|
||||
}, 5000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
});
|
||||
}, [messages]);
|
||||
|
||||
const settingsPages = [
|
||||
{
|
||||
href: '/settings/general',
|
||||
icon: '⚙️',
|
||||
title: 'Paramètres généraux',
|
||||
description: 'Interface, thème, préférences d\'affichage',
|
||||
status: 'Fonctionnel'
|
||||
},
|
||||
{
|
||||
href: '/settings/integrations',
|
||||
icon: '🔌',
|
||||
title: 'Intégrations',
|
||||
description: 'Jira, GitHub, Slack et autres services externes',
|
||||
status: 'Fonctionnel'
|
||||
},
|
||||
{
|
||||
href: '/settings/backup',
|
||||
icon: '💾',
|
||||
title: 'Sauvegardes',
|
||||
description: 'Gestion des sauvegardes automatiques et manuelles',
|
||||
status: 'Fonctionnel'
|
||||
},
|
||||
{
|
||||
href: '/settings/advanced',
|
||||
icon: '🛠️',
|
||||
title: 'Paramètres avancés',
|
||||
description: 'Logs, debug et maintenance système',
|
||||
status: 'Fonctionnel'
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<UserPreferencesProvider initialPreferences={initialPreferences}>
|
||||
<div className="min-h-screen bg-[var(--background)]">
|
||||
<Header
|
||||
title="TowerControl"
|
||||
subtitle="Configuration & Paramètres"
|
||||
/>
|
||||
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Page Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-mono font-bold text-[var(--foreground)] mb-3">
|
||||
Paramètres
|
||||
</h1>
|
||||
<p className="text-[var(--muted-foreground)] text-lg">
|
||||
Configuration de TowerControl et de ses intégrations
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">🎨</span>
|
||||
<div>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">Thème actuel</p>
|
||||
<p className="font-medium capitalize">{initialPreferences.viewPreferences.theme}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">🔌</span>
|
||||
<div>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">Jira</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium">
|
||||
{initialPreferences.jiraConfig.enabled ? 'Configuré' : 'Non configuré'}
|
||||
</p>
|
||||
{initialPreferences.jiraConfig.enabled && (
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full" title="Jira configuré"></span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">📏</span>
|
||||
<div>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">Taille police</p>
|
||||
<p className="font-medium capitalize">{initialPreferences.viewPreferences.fontSize}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">💾</span>
|
||||
<div>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">Sauvegardes</p>
|
||||
<p className="font-medium">
|
||||
{systemInfo ? systemInfo.database.totalBackups : '...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Settings Sections */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold text-[var(--foreground)] mb-4">
|
||||
Sections de configuration
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-1 gap-4">
|
||||
{settingsPages.map((page) => (
|
||||
<Link key={page.href} href={page.href}>
|
||||
<Card className="transition-all hover:shadow-md hover:border-[var(--primary)]/30 cursor-pointer">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
<span className="text-3xl">{page.icon}</span>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-[var(--foreground)] mb-1">
|
||||
{page.title}
|
||||
</h3>
|
||||
<p className="text-[var(--muted-foreground)] mb-2">
|
||||
{page.description}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
page.status === 'Fonctionnel'
|
||||
? 'bg-[var(--success)]/20 text-[var(--success)]'
|
||||
: page.status === 'En développement'
|
||||
? 'bg-[var(--warning)]/20 text-[var(--warning)]'
|
||||
: 'bg-[var(--muted)]/20 text-[var(--muted-foreground)]'
|
||||
}`}>
|
||||
{page.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
className="w-5 h-5 text-[var(--muted-foreground)]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-semibold text-[var(--foreground)] mb-4">
|
||||
Actions rapides
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium mb-1">Sauvegarde manuelle</h3>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Créer une sauvegarde des données
|
||||
</p>
|
||||
{messages.backup && (
|
||||
<p className={`text-xs mt-1 ${
|
||||
messages.backup.type === 'success'
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-red-600 dark:text-red-400'
|
||||
}`}>
|
||||
{messages.backup.text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCreateBackup}
|
||||
disabled={isBackupLoading}
|
||||
className="px-3 py-1.5 bg-[var(--primary)] text-[var(--primary-foreground)] rounded text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isBackupLoading ? 'En cours...' : 'Sauvegarder'}
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium mb-1">Test Jira</h3>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Tester la connexion Jira
|
||||
</p>
|
||||
{messages.jira && (
|
||||
<p className={`text-xs mt-1 ${
|
||||
messages.jira.type === 'success'
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-red-600 dark:text-red-400'
|
||||
}`}>
|
||||
{messages.jira.text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleTestJira}
|
||||
disabled={!initialPreferences.jiraConfig.enabled || isJiraTestLoading}
|
||||
className="px-3 py-1.5 bg-[var(--card)] text-[var(--foreground)] border border-[var(--border)] rounded text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isJiraTestLoading ? 'Test...' : 'Tester'}
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System Info */}
|
||||
<Card className="mt-8">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">ℹ️ Informations système</h2>
|
||||
<button
|
||||
onClick={loadSystemInfo}
|
||||
disabled={isSystemInfoLoading}
|
||||
className="text-xs px-2 py-1 bg-[var(--card)] border border-[var(--border)] rounded hover:bg-[var(--card-hover)] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSystemInfoLoading ? '🔄 Chargement...' : '🔄 Actualiser'}
|
||||
</button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{systemInfo ? (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 text-sm mb-4">
|
||||
<div>
|
||||
<p className="text-[var(--muted-foreground)]">Version</p>
|
||||
<p className="font-medium">TowerControl v{systemInfo.version}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[var(--muted-foreground)]">Dernière maj</p>
|
||||
<p className="font-medium">{systemInfo.lastUpdate}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[var(--muted-foreground)]">Environnement</p>
|
||||
<p className="font-medium capitalize">{systemInfo.environment}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[var(--muted-foreground)]">Uptime</p>
|
||||
<p className="font-medium">{systemInfo.uptime}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[var(--border)] pt-4">
|
||||
<h3 className="text-sm font-medium mb-3 text-[var(--muted-foreground)]">Base de données</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-[var(--muted-foreground)]">Tâches</p>
|
||||
<p className="font-medium">{systemInfo.database.totalTasks}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[var(--muted-foreground)]">Utilisateurs</p>
|
||||
<p className="font-medium">{systemInfo.database.totalUsers}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[var(--muted-foreground)]">Sauvegardes</p>
|
||||
<p className="font-medium">{systemInfo.database.totalBackups}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[var(--muted-foreground)]">Taille DB</p>
|
||||
<p className="font-medium">{systemInfo.database.databaseSize}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-4">
|
||||
<p className="text-[var(--muted-foreground)]">Chargement des informations système...</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UserPreferencesProvider>
|
||||
);
|
||||
}
|
||||
143
src/components/settings/SettingsPageClient.tsx
Normal file
143
src/components/settings/SettingsPageClient.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Header } from '@/components/ui/Header';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { JiraConfigForm } from '@/components/settings/JiraConfigForm';
|
||||
import { JiraSync } from '@/components/jira/JiraSync';
|
||||
import { JiraLogs } from '@/components/jira/JiraLogs';
|
||||
import { useJiraConfig } from '@/hooks/useJiraConfig';
|
||||
|
||||
export function SettingsPageClient() {
|
||||
const { config: jiraConfig } = useJiraConfig();
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'integrations' | 'advanced'>('general');
|
||||
|
||||
const tabs = [
|
||||
{ id: 'general' as const, label: 'Général', icon: '⚙️' },
|
||||
{ id: 'integrations' as const, label: 'Intégrations', icon: '🔌' },
|
||||
{ id: 'advanced' as const, label: 'Avancé', icon: '🛠️' }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--background)]">
|
||||
<Header
|
||||
title="TowerControl"
|
||||
subtitle="Configuration & Paramètres"
|
||||
/>
|
||||
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* En-tête compact */}
|
||||
<div className="mb-4">
|
||||
<h1 className="text-xl font-mono font-bold text-[var(--foreground)] mb-1">
|
||||
Paramètres
|
||||
</h1>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Configuration de TowerControl et de ses intégrations
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-6">
|
||||
{/* Navigation latérale compacte */}
|
||||
<div className="w-56 flex-shrink-0">
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 text-left transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'bg-[var(--primary)]/10 text-[var(--primary)] border-r-2 border-[var(--primary)]'
|
||||
: 'text-[var(--muted-foreground)] hover:bg-[var(--card-hover)] hover:text-[var(--foreground)]'
|
||||
}`}
|
||||
>
|
||||
<span className="text-base">{tab.icon}</span>
|
||||
<span className="font-medium text-sm">{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Contenu principal */}
|
||||
<div className="flex-1 min-h-0">
|
||||
{activeTab === 'general' && (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold">Préférences générales</h2>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Les paramètres généraux seront disponibles dans une prochaine version.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'integrations' && (
|
||||
<div className="h-full">
|
||||
{/* Layout en 2 colonnes pour optimiser l'espace */}
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4 h-full">
|
||||
|
||||
{/* Colonne 1: Configuration Jira */}
|
||||
<div className="xl:col-span-2">
|
||||
<Card className="h-fit">
|
||||
<CardHeader className="pb-3">
|
||||
<h2 className="text-base font-semibold">🔌 Intégration Jira Cloud</h2>
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
Synchronisation automatique des tickets
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<JiraConfigForm />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Colonne 2: Actions et Logs */}
|
||||
<div className="space-y-4">
|
||||
{jiraConfig?.enabled && (
|
||||
<>
|
||||
<JiraSync />
|
||||
<JiraLogs />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'advanced' && (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold">Paramètres avancés</h2>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Les paramètres avancés seront disponibles dans une prochaine version.
|
||||
</p>
|
||||
<ul className="mt-2 text-xs text-[var(--muted-foreground)] space-y-1">
|
||||
<li>• Configuration de la base de données</li>
|
||||
<li>• Logs de debug</li>
|
||||
<li>• Export/Import des données</li>
|
||||
<li>• Réinitialisation</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user