init aegis

This commit is contained in:
maximus
2026-07-22 14:41:21 +02:00
commit 82075f3e6c
33 changed files with 5486 additions and 0 deletions
+94
View File
@@ -0,0 +1,94 @@
import React from 'react';
export default function AccountSettings() {
return (
<div className="min-h-screen bg-slate-50 font-sans text-slate-900 selection:bg-blue-900 selection:text-white">
{/* Header */}
<header className="bg-white border-b border-slate-200 px-8 py-6">
<div className="max-w-7xl mx-auto flex flex-col sm:flex-row justify-between items-start sm:items-center space-y-4 sm:space-y-0">
<div>
<h1 className="text-2xl font-bold text-slate-900 tracking-tight">Paramètres du compte</h1>
<p className="text-sm text-slate-500 mt-1">Gestion de la sécurité et informations contractuelles</p>
</div>
</div>
</header>
{/* Contenu Principal */}
<main className="max-w-7xl mx-auto px-8 py-8 space-y-8">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Colonne 1 : Sécurité */}
<div className="space-y-8">
<section className="bg-white rounded-xl shadow-sm border border-slate-200 p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4 border-b border-slate-100 pb-2 flex items-center">
<svg className="w-5 h-5 mr-2 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
Sécurité de l'accès
</h2>
<div className="space-y-6">
{/* MFA Status */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-slate-900">Authentification à double facteur (MFA)</p>
<p className="text-xs text-slate-500 mt-1">Exigée par les politiques de sécurité GISE</p>
</div>
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-emerald-50 text-emerald-700 border border-emerald-200">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 mr-1.5"></span>
Actif
</span>
</div>
<div className="pt-4 border-t border-slate-100">
<button className="text-sm font-medium text-blue-900 hover:text-blue-800 bg-blue-50 hover:bg-blue-100 px-4 py-2 rounded-lg transition-colors duration-200 border border-blue-100">
Générer de nouveaux codes de secours
</button>
</div>
{/* Mot de passe */}
<div className="pt-4 border-t border-slate-100">
<p className="text-sm font-medium text-slate-900 mb-2">Mot de passe institutionnel</p>
<p className="text-xs text-slate-500 mb-4">Dernière modification : Il y a 43 jours</p>
<button className="text-sm font-medium text-slate-700 hover:text-slate-900 bg-white hover:bg-slate-50 border border-slate-300 px-4 py-2 rounded-lg transition-colors duration-200 shadow-sm">
Modifier le mot de passe
</button>
</div>
</div>
</section>
</div>
{/* Colonne 2 : Organisation & Contrat */}
<div className="space-y-8">
<section className="bg-white rounded-xl shadow-sm border border-slate-200 p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4 border-b border-slate-100 pb-2 flex items-center">
<svg className="w-5 h-5 mr-2 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" /></svg>
Profil de l'Organisation
</h2>
<dl className="space-y-4 text-sm">
<div>
<dt className="text-slate-500 font-medium">Entité Légale</dt>
<dd className="mt-1 font-semibold text-slate-900">Cabinet Juridique Example & Associés</dd>
</div>
<div>
<dt className="text-slate-500 font-medium">Administrateur du compte</dt>
<dd className="mt-1 text-slate-900">Direction Générale (direction@example.com)</dd>
</div>
<div>
<dt className="text-slate-500 font-medium">Niveau d'Infogérance (SLA)</dt>
<dd className="mt-1 flex items-center">
<span className="px-2 py-1 bg-blue-900 text-white text-xs font-bold rounded shadow-sm mr-2 tracking-wider">VIP PLATINUM</span>
<span className="text-slate-700 font-medium">Couverture 24/7 (99.99%)</span>
</dd>
</div>
</dl>
</section>
</div>
</div>
</main>
</div>
);
}
+221
View File
@@ -0,0 +1,221 @@
import React, { useState } from 'react';
import { pb } from '../../config/pocketbase';
import * as OTPAuth from 'otpauth';
import { QRCodeSVG } from 'qrcode.react';
interface LoginProps {
onLoginSuccess: () => void;
}
export default function Login({ onLoginSuccess }: LoginProps) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [mfaCode, setMfaCode] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [step, setStep] = useState<1 | 2>(1);
// Nouveaux états pour la cryptographie TOTP
const [userId, setUserId] = useState('');
const [totpSecret, setTotpSecret] = useState('');
const [qrUrl, setQrUrl] = useState('');
const [isFirstSetup, setIsFirstSetup] = useState(false);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
// 1. Authentification PocketBase
const authData = await pb.collection('aegis_users').authWithPassword(email, password);
// 2. Vérification du MFA
if (authData.record.mfa_enabled) {
setUserId(authData.record.id);
// Si le client n'a pas encore configuré son MFA
if (!authData.record.totp_secret) {
// Génération cryptographique native pour le navigateur
const totp = new OTPAuth.TOTP({
issuer: 'AEGIS by GISE',
label: email,
algorithm: 'SHA1',
digits: 6,
period: 30,
secret: new OTPAuth.Secret({ size: 20 })
});
const newSecret = totp.secret.base32;
const otpauth = totp.toString(); // Génère l'URL pour le QR Code
setTotpSecret(newSecret);
setQrUrl(otpauth);
setIsFirstSetup(true);
} else {
// Le client l'a déjà configuré dans le passé
setTotpSecret(authData.record.totp_secret);
setIsFirstSetup(false);
}
setStep(2);
} else {
onLoginSuccess();
}
} catch (err: any) {
console.error("Erreur d'authentification", err);
setError("Identifiants institutionnels incorrects ou accès révoqué.");
pb.authStore.clear();
} finally {
setIsLoading(false);
}
};
const handleFinalStep = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
// On recrée l'instance TOTP avec le secret pour vérifier le code saisi
const totp = new OTPAuth.TOTP({
issuer: 'AEGIS by GISE',
label: email,
algorithm: 'SHA1',
digits: 6,
period: 30,
secret: OTPAuth.Secret.fromBase32(totpSecret)
});
// Validation : Retourne un nombre si valide, null sinon (tolérance de 1 fenêtre de 30s)
const isValid = totp.validate({ token: mfaCode, window: 1 }) !== null;
if (isValid) {
// Si c'était la première configuration, on sauvegarde le secret en base de données
if (isFirstSetup) {
await pb.collection('aegis_users').update(userId, {
totp_secret: totpSecret
});
}
onLoginSuccess(); // La porte du coffre s'ouvre !
} else {
setError("Code de sécurité invalide ou expiré.");
setMfaCode('');
}
} catch (err) {
console.error(err);
setError("Une erreur critique est survenue lors de la vérification.");
} finally {
setIsLoading(false);
}
};
const handleCancelMFA = () => {
pb.authStore.clear();
setStep(1);
setPassword('');
setMfaCode('');
setError('');
setIsFirstSetup(false);
};
return (
<div className="min-h-screen bg-slate-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8 font-sans selection:bg-blue-900 selection:text-white">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<div className="flex justify-center">
<div className="h-12 w-12 bg-blue-900 rounded-lg flex items-center justify-center shadow-sm border border-blue-800">
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
</div>
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-slate-900">
AEGIS <span className="font-light text-slate-500">by GISE</span>
</h2>
<p className="mt-2 text-center text-sm text-slate-500 uppercase tracking-widest font-semibold">
Accès restreint
</p>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-white py-8 px-4 shadow-sm border border-slate-200 rounded-xl sm:px-10">
{step === 1 ? (
<form className="space-y-6" onSubmit={handleLogin}>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md text-sm font-medium animate-in fade-in slide-in-from-top-1">
{error}
</div>
)}
<div>
<label htmlFor="email" className="block text-sm font-medium text-slate-700">Identifiant institutionnel</label>
<div className="mt-1">
<input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required className="block w-full appearance-none rounded-md border border-slate-300 px-3 py-2 placeholder-slate-400 shadow-sm focus:border-blue-900 focus:outline-none focus:ring-blue-900 sm:text-sm" placeholder="direction@client.com" />
</div>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-slate-700">Mot de passe</label>
<div className="mt-1">
<input id="password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} required className="block w-full appearance-none rounded-md border border-slate-300 px-3 py-2 placeholder-slate-400 shadow-sm focus:border-blue-900 focus:outline-none focus:ring-blue-900 sm:text-sm" placeholder="••••••••••••" />
</div>
</div>
<div>
<button type="submit" disabled={isLoading} className="flex w-full justify-center rounded-md border border-transparent bg-blue-900 py-2.5 px-4 text-sm font-medium text-white shadow-sm hover:bg-blue-800 focus:outline-none transition-colors disabled:opacity-70">
{isLoading ? 'Chiffrement en cours...' : 'Authentification'}
</button>
</div>
</form>
) : (
<form className="space-y-6 animate-in fade-in slide-in-from-right-4 duration-300" onSubmit={handleFinalStep}>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md text-sm font-medium animate-in fade-in slide-in-from-top-1">
{error}
</div>
)}
{isFirstSetup ? (
<div className="text-center mb-6">
<p className="text-sm font-medium text-slate-900">Configuration de la sécurité</p>
<p className="text-xs text-slate-500 mt-2 mb-4">Scannez ce QR Code avec Google Authenticator ou Authy pour lier votre appareil.</p>
<div className="flex justify-center p-4 bg-white border border-slate-200 rounded-lg inline-block shadow-sm">
<QRCodeSVG value={qrUrl} size={150} />
</div>
</div>
) : (
<div className="text-center mb-6">
<p className="text-sm font-medium text-slate-900">Validation à double facteur</p>
<p className="text-xs text-slate-500 mt-1">Saisissez le code généré par votre application d'authentification.</p>
</div>
)}
<div>
<input
type="text"
value={mfaCode}
onChange={(e) => setMfaCode(e.target.value)}
required
maxLength={6}
className="block w-full appearance-none rounded-md border border-slate-300 px-3 py-3 text-center text-2xl tracking-[0.5em] text-slate-900 placeholder-slate-300 shadow-sm focus:border-blue-900 focus:outline-none font-mono"
placeholder="000000"
/>
</div>
<div className="flex space-x-3">
<button type="button" onClick={handleCancelMFA} disabled={isLoading} className="flex w-1/3 justify-center rounded-md border border-slate-300 bg-white py-2.5 px-4 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50 transition-colors disabled:opacity-70">
Annuler
</button>
<button type="submit" disabled={isLoading || mfaCode.length !== 6} className="flex w-2/3 justify-center rounded-md border border-transparent bg-emerald-600 py-2.5 px-4 text-sm font-medium text-white shadow-sm hover:bg-emerald-700 transition-colors disabled:opacity-70">
{isLoading ? 'Vérification...' : 'Déverrouiller'}
</button>
</div>
</form>
)}
</div>
</div>
</div>
);
}
+216
View File
@@ -0,0 +1,216 @@
import React, { useState } from 'react';
import { pb } from '../../config/pocketbase';
interface NewTicketProps {
onCancel: () => void;
onTicketCreated: () => void; // Rappel pour recharger la liste et revenir en arrière
}
export default function NewTicket({ onCancel, onTicketCreated }: NewTicketProps) {
const [category, setCategory] = useState<'Incident Critique' | 'Évolution' | 'Administratif'>('Incident Critique');
const [ci, Ci] = useState('general');
const [subject, setSubject] = useState('');
const [affectedAsset, setAffectedAsset] = useState('');
const [incidentTime, setIncidentTime] = useState('');
const [description, setDescription] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsSubmitting(true);
try {
const currentUser = pb.authStore.record;
if (!currentUser) throw new Error("Utilisateur non authentifié");
// Construction de la description enrichie avec les métadonnées techniques
let fullDescription = description;
if (affectedAsset.trim()) {
fullDescription = `[Équipement concerné: ${affectedAsset}]\n` + fullDescription;
}
if (category === 'Incident Critique' && incidentTime) {
fullDescription = `[Heure de l'incident: ${new Date(incidentTime).toLocaleString('fr-FR')}]\n` + fullDescription;
}
// Enregistrement dans PocketBase
await pb.collection('aegis_tickets').create({
author: currentUser.id,
company: currentUser.company,
category,
subject,
description: fullDescription,
status: 'Ouvert',
});
onTicketCreated(); // Retourne à la liste et rafraîchit
} catch (err) {
console.error("Erreur lors de la création du ticket :", err);
setError("Impossible d'enregistrer le ticket sur le serveur sécurisé.");
} finally {
setIsSubmitting(false);
}
};
return (
<div className="min-h-screen bg-slate-50 font-sans text-slate-900 pb-12">
{/* Header */}
<header className="bg-white border-b border-slate-200 px-8 py-4 sticky top-0 z-10 shadow-sm">
<div className="max-w-7xl mx-auto flex items-center">
<button
type="button"
onClick={onCancel}
className="mr-4 p-2 text-slate-400 hover:text-slate-900 hover:bg-slate-50 rounded-full transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
</button>
<div>
<h1 className="text-xl font-bold text-slate-900 tracking-tight">Ouvrir un nouveau ticket</h1>
<p className="text-sm text-slate-500 mt-0.5">Qualification de votre requête ITSM</p>
</div>
</div>
</header>
{/* Formulaire Principal */}
<main className="max-w-4xl mx-auto px-8 pt-8">
<form onSubmit={handleSubmit} className="space-y-8 bg-white rounded-xl shadow-sm border border-slate-200 p-8">
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md text-sm font-medium">
{error}
</div>
)}
{/* Section 1 : Qualification */}
<div>
<h2 className="text-base font-semibold text-slate-900 mb-4 border-b border-slate-100 pb-2">1. Qualification de la demande</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label htmlFor="category" className="block text-sm font-medium text-slate-700 mb-1">Type de demande</label>
<select
id="category"
value={category}
onChange={(e) => setCategory(e.target.value as any)}
className="block w-full rounded-md border border-slate-300 py-2.5 px-3 text-sm focus:border-blue-900 focus:outline-none focus:ring-1 focus:ring-blue-900 bg-white"
>
<option value="Incident Critique">Incident Critique (Panne, Dégradation)</option>
<option value="Évolution">Évolution (Ajout de ressources, Modification)</option>
<option value="Administratif">Administratif (Facturation, Contrat)</option>
</select>
</div>
<div>
<label htmlFor="ci" className="block text-sm font-medium text-slate-700 mb-1">Infrastructure concernée (CI)</label>
<select
id="ci"
value={ci}
onChange={(e) => Ci(e.target.value)}
className="block w-full rounded-md border border-slate-300 py-2.5 px-3 text-sm focus:border-blue-900 focus:outline-none focus:ring-1 focus:ring-blue-900 bg-white"
>
<option value="general">Général / Non spécifique</option>
<option value="px1">Serveur Proxmox Principal (Hyperviseur)</option>
<option value="fw1">Pare-feu Périphérique (WAN)</option>
<option value="db1">Base de données MariaDB</option>
</select>
</div>
</div>
</div>
{/* Section 2 : Détails techniques & Actifs */}
<div>
<h2 className="text-base font-semibold text-slate-900 mb-4 border-b border-slate-100 pb-2">2. Détails techniques</h2>
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label htmlFor="subject" className="block text-sm font-medium text-slate-700 mb-1">Sujet de l'intervention <span className="text-blue-900 font-bold">*</span></label>
<input
type="text"
id="subject"
required
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="Ex: Perte de paquets sur le lien WAN principal"
className="block w-full rounded-md border border-slate-300 py-2.5 px-3 text-sm focus:border-blue-900 focus:outline-none focus:ring-1 focus:ring-blue-900"
/>
</div>
<div>
<label htmlFor="affectedAsset" className="block text-sm font-medium text-slate-700 mb-1">
Équipement ou Produit concerné <span className="text-slate-400 font-normal">(Optionnel)</span>
</label>
<input
type="text"
id="affectedAsset"
value={affectedAsset}
onChange={(e) => setAffectedAsset(e.target.value)}
placeholder="Ex: Ordinateur Direction, Imprimante X, etc."
className="block w-full rounded-md border border-slate-300 py-2.5 px-3 text-sm focus:border-blue-900 focus:outline-none focus:ring-1 focus:ring-blue-900"
/>
</div>
</div>
{category === 'Incident Critique' && (
<div>
<label htmlFor="incidentTime" className="block text-sm font-medium text-slate-700 mb-1">
Heure exacte du début de l'incident <span className="text-red-500 font-bold">*</span>
</label>
<input
type="datetime-local"
id="incidentTime"
required
value={incidentTime}
onChange={(e) => setIncidentTime(e.target.value)}
className="block w-full max-w-md rounded-md border border-slate-300 py-2.5 px-3 text-sm focus:border-blue-900 focus:outline-none focus:ring-1 focus:ring-blue-900 bg-white"
/>
</div>
)}
<div>
<label htmlFor="description" className="block text-sm font-medium text-slate-700 mb-1">
Description détaillée <span className="text-blue-900 font-bold">*</span>
</label>
<textarea
id="description"
required
rows={6}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Décrivez précisément votre besoin ou les symptômes observés..."
className="block w-full rounded-md border border-slate-300 py-2.5 px-3 text-sm focus:border-blue-900 focus:outline-none focus:ring-1 focus:ring-blue-900 resize-y"
/>
</div>
</div>
</div>
{/* Actions */}
<div className="pt-4 flex justify-end space-x-3 border-t border-slate-100">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 transition-colors"
>
Annuler
</button>
<button
type="submit"
disabled={isSubmitting}
className={`px-6 py-2 text-sm font-medium text-white rounded-lg transition-colors shadow-sm ${
category === 'Incident Critique' ? 'bg-red-600 hover:bg-red-700 focus:ring-red-600' : 'bg-blue-900 hover:bg-blue-800 focus:ring-blue-900'
} focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50`}
>
{isSubmitting ? 'Transmission en cours...' : 'Soumettre le ticket'}
</button>
</div>
</form>
</main>
</div>
);
}
+154
View File
@@ -0,0 +1,154 @@
import React, { useState, useEffect } from 'react';
import { pb } from '../../config/pocketbase';
import NewTicket from './NewTicket'; // Ajustez le chemin d'import selon votre arborescence
import TicketDetail from './TicketDetail'; // Ajustez le chemin d'import selon votre arborescence
interface Ticket {
id: string;
subject: string;
category: string;
status: 'Ouvert' | 'En analyse' | 'Résolu';
created: string;
}
export default function ServiceDesk() {
const [tickets, setTickets] = useState<Ticket[]>([]);
const [isLoading, setIsLoading] = useState(true);
// États de navigation interne au module Service Desk
const [currentView, setCurrentView] = useState<'list' | 'new' | 'detail'>('list');
const [selectedTicketId, setSelectedTicketId] = useState<string | null>(null);
// Récupération des tickets de l'entreprise connectée
const fetchTickets = async () => {
try {
const records = await pb.collection('aegis_tickets').getFullList<Ticket>({
sort: '-created', // Du plus récent au plus ancien
});
setTickets(records);
} catch (error) {
console.error("Erreur lors de la récupération des tickets :", error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchTickets();
}, []);
// Gestion du clic sur un ticket pour ouvrir la discussion
const handleSelectTicket = (id: string) => {
setSelectedTicketId(id);
setCurrentView('detail');
};
// Styles visuels pour les statuts
const getStatusStyle = (status: Ticket['status']) => {
switch (status) {
case 'Ouvert': return 'bg-blue-50 text-blue-700 border-blue-100';
case 'En analyse': return 'bg-amber-50 text-amber-700 border-amber-100';
case 'Résolu': return 'bg-emerald-50 text-emerald-700 border-emerald-100';
default: return 'bg-slate-50 text-slate-700 border-slate-100';
}
};
// Rendu conditionnel selon la vue active
if (currentView === 'new') {
return (
<NewTicket
onCancel={() => setCurrentView('list')}
onTicketCreated={() => {
setCurrentView('list');
fetchTickets(); // On recharge la liste pour afficher le nouveau ticket
}}
/>
);
}
if (currentView === 'detail' && selectedTicketId) {
return (
<TicketDetail
ticketId={selectedTicketId}
onBack={() => {
setCurrentView('list');
setSelectedTicketId(null);
fetchTickets();
}}
/>
);
}
// Vue par défaut : La liste des tickets
return (
<div className="min-h-screen bg-slate-50 font-sans text-slate-900 relative">
{/* Header */}
<header className="bg-white border-b border-slate-200 px-8 py-6">
<div className="max-w-7xl mx-auto flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-slate-900 tracking-tight">Centre de Support (Service Desk)</h1>
<p className="text-sm text-slate-500 mt-1">Canal de communication sécurisé avec vos experts GISE.</p>
</div>
<div className="flex items-center space-x-3">
<button
onClick={() => setCurrentView('new')}
className="bg-blue-900 hover:bg-blue-800 text-white px-4 py-2.5 rounded-lg text-sm font-medium transition-colors shadow-sm"
>
Ouvrir un ticket sécurisé
</button>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-8 py-8">
{/* Liste des tickets */}
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
<div className="p-6 border-b border-slate-100">
<h2 className="text-lg font-semibold text-slate-900">Requêtes en cours et historiques</h2>
</div>
{isLoading ? (
<div className="flex justify-center items-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-900"></div>
</div>
) : tickets.length === 0 ? (
<div className="text-center py-12 text-sm text-slate-500 italic">
Aucun ticket enregistré pour le moment.
</div>
) : (
<div className="divide-y divide-slate-100">
{tickets.map((ticket) => (
<div
key={ticket.id}
onClick={() => handleSelectTicket(ticket.id)}
className="p-6 flex items-center justify-between hover:bg-slate-50 transition-colors cursor-pointer group"
>
<div className="space-y-1">
<div className="flex items-center space-x-3">
<span className="text-xs font-semibold px-2 py-0.5 bg-slate-100 text-slate-700 rounded border border-slate-200">
{ticket.category}
</span>
<span className="text-xs text-slate-400">
Ouvert le {new Date(ticket.created).toLocaleDateString('fr-FR')}
</span>
</div>
<h3 className="text-sm font-semibold text-slate-900 group-hover:text-blue-900 transition-colors">
{ticket.subject}
</h3>
</div>
<div>
<span className={`px-3 py-1 rounded-full text-xs font-medium border ${getStatusStyle(ticket.status)}`}>
{ticket.status}
</span>
</div>
</div>
))}
</div>
)}
</div>
</main>
</div>
);
}
+200
View File
@@ -0,0 +1,200 @@
import React, { useState, useEffect } from 'react';
import { pb } from '../../config/pocketbase';
interface Ticket {
id: string;
subject: string;
description: string;
category: string;
status: 'Ouvert' | 'En analyse' | 'Résolu';
created: string;
}
interface Message {
id: string;
content: string;
created: string;
expand?: {
author?: {
name?: string;
email?: string;
avatar?: string;
};
};
}
interface TicketDetailProps {
ticketId: string;
onBack: () => void;
}
export default function TicketDetail({ ticketId, onBack }: TicketDetailProps) {
const [ticket, setTicket] = useState<Ticket | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [newMessage, setNewMessage] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [isSending, setIsSending] = useState(false);
const currentUser = pb.authStore.record;
// Récupération du ticket et de ses messages associés
const fetchTicketData = async () => {
try {
const ticketData = await pb.collection('aegis_tickets').getOne<Ticket>(ticketId);
setTicket(ticketData);
const messageRecords = await pb.collection('ticket_messages').getFullList<Message>({
filter: `ticket = "${ticketId}"`,
expand: 'author',
sort: 'created',
});
setMessages(messageRecords);
} catch (error) {
console.error("Erreur lors du chargement de la discussion :", error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchTicketData();
}, [ticketId]);
// Envoi d'un nouveau message dans la discussion
const handleSendMessage = async (e: React.FormEvent) => {
e.preventDefault();
if (!newMessage.trim() || !currentUser) return;
setIsSending(true);
try {
await pb.collection('ticket_messages').create({
ticket: ticketId,
author: currentUser.id,
content: newMessage.trim(),
});
setNewMessage('');
await fetchTicketData(); // Recharger les messages
} catch (error) {
console.error("Erreur lors de l'envoi du message :", error);
alert("Impossible d'envoyer le message.");
} finally {
setIsSending(false);
}
};
if (isLoading) {
return (
<div className="flex justify-center items-center py-20">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-900"></div>
</div>
);
}
if (!ticket) {
return (
<div className="bg-white rounded-xl p-6 text-center border border-slate-200">
<p className="text-sm text-slate-500 mb-4">Ticket introuvable ou accès non autorisé.</p>
<button onClick={onBack} className="px-4 py-2 bg-slate-900 text-white rounded-lg text-sm font-medium">
Retour aux tickets
</button>
</div>
);
}
return (
<div className="space-y-6 max-w-4xl mx-auto">
{/* Barre de navigation / Retour */}
<div className="flex items-center justify-between bg-white p-4 rounded-xl border border-slate-200 shadow-sm">
<button
onClick={onBack}
className="flex items-center space-x-2 text-sm font-medium text-slate-600 hover:text-slate-900 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 19l-7-7 7-7" />
</svg>
<span>Retour au Centre de Support</span>
</button>
<div className="flex items-center space-x-3">
<span className="text-xs font-semibold px-2.5 py-1 bg-slate-100 text-slate-700 rounded border border-slate-200 uppercase">
{ticket.category}
</span>
<span className={`px-3 py-1 rounded-full text-xs font-medium border ${
ticket.status === 'Résolu' ? 'bg-emerald-50 text-emerald-700 border-emerald-100' : 'bg-blue-50 text-blue-700 border-blue-100'
}`}>
{ticket.status}
</span>
</div>
</div>
{/* En-tête du ticket & Description initiale */}
<div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm space-y-4">
<h1 className="text-xl font-bold text-slate-900">{ticket.subject}</h1>
<div className="bg-slate-50 p-4 rounded-lg border border-slate-100 text-sm text-slate-700 whitespace-pre-wrap">
<p className="text-xs font-semibold text-slate-400 mb-1">Description initiale :</p>
{ticket.description}
</div>
<p className="text-xs text-slate-400">
Créé le {new Date(ticket.created).toLocaleString('fr-FR')}
</p>
</div>
{/* Fil de discussion / Messages */}
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden flex flex-col h-[500px]">
<div className="p-4 border-b border-slate-100 bg-slate-50/50">
<h2 className="text-sm font-semibold text-slate-800">Échanges avec l'équipe GISE</h2>
</div>
{/* Liste des messages */}
<div className="flex-1 overflow-y-auto p-6 space-y-4">
{messages.length === 0 ? (
<div className="text-center py-12 text-sm text-slate-400 italic">
Aucun message pour l'instant. Démarrez la conversation ci-dessous.
</div>
) : (
messages.map((msg) => {
const isMyMessage = msg.author === currentUser?.id;
const authorName = msg.expand?.author?.name || msg.expand?.author?.email || 'Expert GISE';
return (
<div key={msg.id} className={`flex flex-col ${isMyMessage ? 'items-end' : 'items-start'}`}>
<div className="flex items-center space-x-2 mb-1">
<span className="text-xs font-medium text-slate-600">{authorName}</span>
<span className="text-[10px] text-slate-400">
{new Date(msg.created).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
<div className={`max-w-[80%] rounded-2xl px-4 py-3 text-sm shadow-sm ${
isMyMessage
? 'bg-blue-900 text-white rounded-br-none'
: 'bg-slate-100 text-slate-800 rounded-bl-none border border-slate-200/60'
}`}>
{msg.content}
</div>
</div>
);
})
)}
</div>
{/* Formulaire de réponse */}
<form onSubmit={handleSendMessage} className="p-4 border-t border-slate-100 bg-white flex items-center space-x-3">
<input
type="text"
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
placeholder="Écrivez votre message ou complément d'information..."
className="flex-1 rounded-lg border border-slate-300 px-4 py-2.5 text-sm focus:border-blue-900 focus:outline-none focus:ring-1 focus:ring-blue-900"
/>
<button
type="submit"
disabled={isSending || !newMessage.trim()}
className="bg-blue-900 hover:bg-blue-800 text-white px-5 py-2.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50 shadow-sm"
>
{isSending ? 'Envoi...' : 'Envoyer'}
</button>
</form>
</div>
</div>
);
}
+200
View File
@@ -0,0 +1,200 @@
import { useState } from 'react';
import UptimeWidget from '../../components/widgets/UptimeWidget';
import BackupWidget from '../../components/widgets/BackupWidget';
import SecurityWidget from '../../components/widgets/SecurityWidget';
import ServicesWidget from '../../components/widgets/ServicesWidget';
interface TrustCenterProps {
onNavigate?: (view: 'dashboard' | 'support' | 'vault' | 'account') => void;
}
export default function TrustCenter({ onNavigate }: TrustCenterProps) {
const [activeModal, setActiveModal] = useState<'none' | 'evolution' | 'analytics' | 'backups'>('none');
const [selectedService, setSelectedService] = useState<string>('');
const [evolutionChoice, setEvolutionChoice] = useState<string>('');
const closeModal = () => setActiveModal('none');
return (
<div className="min-h-screen bg-slate-50 font-sans text-slate-900 relative">
{/* Header */}
<header className="bg-white border-b border-slate-200 px-8 py-6">
<div className="max-w-7xl mx-auto flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-slate-900 tracking-tight">Tableau de bord de l'infrastructure</h1>
<p className="text-sm text-slate-500 mt-1">Espace sécurisé AEGIS • Synchronisation en temps réel</p>
</div>
<div className="flex items-center space-x-3">
<span className="flex h-3 w-3 relative">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-3 w-3 bg-emerald-500"></span>
</span>
<span className="text-sm font-medium text-slate-700">Connexion chiffrée</span>
</div>
</div>
</header>
{/* Main Grid */}
<main className="max-w-7xl mx-auto px-8 py-8">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
<UptimeWidget />
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<BackupWidget onClick={() => setActiveModal('backups')} />
<SecurityWidget />
</div>
</div>
<div className="space-y-6">
<ServicesWidget
onEvolutionClick={() => setActiveModal('evolution')}
onServiceClick={(service) => {
setSelectedService(service);
setActiveModal('analytics');
}}
/>
{/* CTA Support ITSM Actif */}
<div className="bg-blue-900 rounded-xl shadow-sm border border-blue-800 p-6 text-white">
<h3 className="text-lg font-semibold mb-2">Centre de Support</h3>
<p className="text-blue-200 text-sm mb-4 leading-relaxed">
Déclarez un incident critique ou demandez une évolution de votre infrastructure.
</p>
<button
onClick={() => onNavigate && onNavigate('support')}
className="w-full bg-white text-blue-900 hover:bg-slate-50 font-medium py-2.5 px-4 rounded-lg transition-colors shadow-sm"
>
Ouvrir un ticket sécurisé
</button>
</div>
</div>
</div>
</main>
{/* --- OVERLAY MODALES --- */}
{activeModal !== 'none' && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/60 backdrop-blur-sm">
{/* MODALE 1 : ÉVOLUTION STRATÉGIQUE */}
{activeModal === 'evolution' && (
<div className="bg-white rounded-2xl shadow-xl w-full max-w-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-200">
<div className="px-6 py-4 border-b border-slate-100 flex justify-between items-center bg-slate-50">
<h3 className="text-lg font-bold text-slate-900">Demande d'évolution d'infrastructure</h3>
<button onClick={closeModal} className="text-slate-400 hover:text-slate-900"><svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" /></svg></button>
</div>
<div className="p-6">
<h4 className="text-base font-semibold text-slate-900 mb-4">Quel est votre prochain objectif d'infrastructure ?</h4>
<div className="space-y-3">
{[
{ id: 'sec', title: 'Renforcer la sécurité face aux cybermenaces', desc: 'Audits de vulnérabilité, Plan de Reprise d\'Activité (PRA)' },
{ id: 'net', title: 'Étendre les capacités du réseau actuel', desc: 'Migration Cloud Privé, ouverture de nouveaux sites' },
{ id: 'gov', title: 'Mise en conformité légale & Gouvernance', desc: 'Accompagnement RGPD, Directive NIS2, vCISO' },
{ id: 'oth', title: 'Autre demande stratégique', desc: 'Outils souverains, audit spécifique' }
].map((option) => (
<div
key={option.id}
onClick={() => setEvolutionChoice(option.id)}
className={`p-4 rounded-xl border-2 cursor-pointer transition-all ${evolutionChoice === option.id ? 'border-blue-900 bg-blue-50' : 'border-slate-200 hover:border-blue-300'}`}
>
<p className="font-semibold text-slate-900">{option.title}</p>
<p className="text-sm text-slate-500 mt-1">{option.desc}</p>
</div>
))}
</div>
<div className="mt-8 flex justify-end space-x-3">
<button onClick={closeModal} className="px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 rounded-lg">Annuler</button>
<button onClick={() => { alert('Redirection vers un ticket projet avec le choix stratégique.'); closeModal(); }} className="px-6 py-2 text-sm font-medium text-white bg-blue-900 hover:bg-blue-800 rounded-lg shadow-sm">Valider la demande stratégique</button>
</div>
</div>
</div>
)}
{/* MODALE 2 : ANALYTICS SERVICES */}
{activeModal === 'analytics' && (
<div className="bg-white rounded-2xl shadow-xl w-full max-w-3xl overflow-hidden animate-in fade-in zoom-in-95 duration-200">
<div className="px-6 py-4 border-b border-slate-100 flex justify-between items-center bg-slate-50">
<div>
<h3 className="text-lg font-bold text-slate-900">Télémétrie & Analytics</h3>
<p className="text-sm text-slate-500">{selectedService}</p>
</div>
<button onClick={closeModal} className="text-slate-400 hover:text-slate-900"><svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" /></svg></button>
</div>
<div className="p-6">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<div className="bg-slate-50 border border-slate-100 p-4 rounded-xl text-center">
<p className="text-xs text-slate-500 uppercase font-semibold mb-1">Disponibilité (30j)</p>
<p className="text-2xl font-bold text-emerald-600">100%</p>
</div>
<div className="bg-slate-50 border border-slate-100 p-4 rounded-xl text-center">
<p className="text-xs text-slate-500 uppercase font-semibold mb-1">Charge CPU moy.</p>
<p className="text-2xl font-bold text-slate-900">14%</p>
</div>
<div className="bg-slate-50 border border-slate-100 p-4 rounded-xl text-center">
<p className="text-xs text-slate-500 uppercase font-semibold mb-1">Trafic chiffré</p>
<p className="text-2xl font-bold text-slate-900">1.2 TB</p>
</div>
<div className="bg-slate-50 border border-slate-100 p-4 rounded-xl text-center">
<p className="text-xs text-slate-500 uppercase font-semibold mb-1">Requêtes bloquées</p>
<p className="text-2xl font-bold text-blue-900">3,492</p>
</div>
</div>
{/* Représentation visuelle d'un graphe */}
<h4 className="text-sm font-semibold text-slate-900 mb-3">Trafic Réseau WAN (Dernières 24h)</h4>
<div className="h-32 w-full bg-slate-50 border border-slate-100 rounded-lg flex items-end p-2 space-x-1">
{[40, 20, 60, 80, 50, 30, 70, 90, 60, 40, 30, 20, 10, 50, 80, 60, 40, 70, 90, 100, 80, 60, 40, 50].map((val, i) => (
<div key={i} className="bg-blue-200 hover:bg-blue-400 w-full rounded-t-sm transition-colors" style={{ height: `${val}%` }}></div>
))}
</div>
</div>
</div>
)}
{/* MODALE 3 : HISTORIQUE DES SAUVEGARDES */}
{activeModal === 'backups' && (
<div className="bg-white rounded-2xl shadow-xl w-full max-w-3xl overflow-hidden animate-in fade-in zoom-in-95 duration-200">
<div className="px-6 py-4 border-b border-slate-100 flex justify-between items-center bg-slate-50">
<h3 className="text-lg font-bold text-slate-900">Historique des Sauvegardes PRA</h3>
<button onClick={closeModal} className="text-slate-400 hover:text-slate-900"><svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" /></svg></button>
</div>
<div className="p-0 overflow-x-auto">
<table className="min-w-full divide-y divide-slate-200">
<thead className="bg-slate-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase">Date d'exécution</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase">Cible (Nœud)</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase">Type</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase">Volume</th>
<th className="px-6 py-3 text-right text-xs font-semibold text-slate-500 uppercase">Statut</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-slate-100">
{[
{ date: 'Aujourd\'hui, 03:00 AM', node: 'Cluster Proxmox (Compta)', type: 'Incrémentielle', size: '12 GB' },
{ date: 'Hier, 03:00 AM', node: 'Cluster Proxmox (Compta)', type: 'Incrémentielle', size: '8.4 GB' },
{ date: 'Dimanche, 01:00 AM', node: 'Cluster Proxmox (Compta)', type: 'Totale (Full)', size: '240 GB' },
{ date: 'Samedi, 03:00 AM', node: 'Cluster Proxmox (Compta)', type: 'Incrémentielle', size: '4.1 GB' },
].map((bkp, i) => (
<tr key={i} className="hover:bg-slate-50">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-slate-900">{bkp.date}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-slate-500">{bkp.node}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-slate-500">{bkp.type}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-slate-500">{bkp.size}</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-emerald-50 text-emerald-700 border border-emerald-100">
Succès (Chiffré)
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
)}
</div>
);
}
+119
View File
@@ -0,0 +1,119 @@
import { useState } from 'react';
// Données fictives pour le maquettage visuel
const mockDocuments = [
{ id: 'DOC-102', title: 'Rapport d\'Audit de Vulnérabilité Q2', category: 'Audit', date: '12 Juil. 2026', size: '2.4 MB', encrypted: true },
{ id: 'DOC-101', title: 'Facture Infogérance - Juin 2026', category: 'Facture', date: '01 Juil. 2026', size: '1.1 MB', encrypted: true },
{ id: 'DOC-095', title: 'Certificat de Conformité ISO 27001', category: 'Rapport de conformité', date: '15 Jan. 2026', size: '4.8 MB', encrypted: true }
];
export default function DocumentVault() {
const [searchTerm, setSearchTerm] = useState('');
const getCategoryBadge = (category: string) => {
switch (category) {
case 'Audit': return 'bg-purple-50 text-purple-700 border-purple-200';
case 'Facture': return 'bg-slate-100 text-slate-700 border-slate-200';
case 'Rapport de conformité': return 'bg-emerald-50 text-emerald-700 border-emerald-200';
default: return 'bg-blue-50 text-blue-700 border-blue-200';
}
};
return (
<div className="min-h-screen bg-slate-50 font-sans text-slate-900 selection:bg-blue-900 selection:text-white">
{/* Header du Coffre-Fort */}
<header className="bg-white border-b border-slate-200 px-8 py-6">
<div className="max-w-7xl mx-auto flex flex-col sm:flex-row justify-between items-start sm:items-center space-y-4 sm:space-y-0">
<div>
<h1 className="text-2xl font-bold text-slate-900 tracking-tight">Coffre-Fort Documentaire</h1>
<p className="text-sm text-slate-500 mt-1 flex items-center">
<svg className="w-4 h-4 mr-1.5 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
Espace de stockage chiffré de bout en bout
</p>
</div>
</div>
</header>
{/* Contenu Principal */}
<main className="max-w-7xl mx-auto px-8 py-8">
{/* Barre de recherche (Visuelle) */}
<div className="mb-6 flex">
<div className="relative flex-1 max-w-lg">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg className="h-5 w-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<input
type="text"
placeholder="Rechercher un document, une facture..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="block w-full pl-10 pr-3 py-2 border border-slate-300 rounded-lg leading-5 bg-white placeholder-slate-400 focus:outline-none focus:ring-1 focus:ring-blue-900 focus:border-blue-900 sm:text-sm shadow-sm"
/>
</div>
</div>
{/* Liste des Documents (La Carte) */}
<div className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
<table className="min-w-full divide-y divide-slate-200">
<thead className="bg-slate-50">
<tr>
<th scope="col" className="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">
Nom du fichier
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">
Catégorie
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">
Ajouté le
</th>
<th scope="col" className="px-6 py-3 text-right text-xs font-semibold text-slate-500 uppercase tracking-wider">
Action
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-slate-200">
{mockDocuments.map((doc) => (
<tr key={doc.id} className="hover:bg-slate-50 transition-colors">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<svg className="flex-shrink-0 h-6 w-6 text-slate-400 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<div>
<div className="text-sm font-medium text-slate-900">{doc.title}</div>
<div className="text-xs text-slate-500">{doc.size} PDF</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border ${getCategoryBadge(doc.category)}`}>
{doc.category}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-slate-500">
{doc.date}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button className="text-blue-900 hover:text-blue-700 bg-blue-50 hover:bg-blue-100 px-3 py-1.5 rounded flex items-center justify-end ml-auto transition-colors">
<svg className="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Télécharger
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</main>
</div>
);
}