Checkpoint: Application SONUM v1.0 - Cartographie des Solutions Numériques FEHAP. Toutes les fonctionnalités implémentées : charte CGU, moteur de recherche multicritères avec tri, Mes Établissements avec accordéons et édition inline, formulaire de saisie guidé en 3 étapes, fiche établissement avec compteur de consultation, prise de contact, Mes Demandes de Contact avec réponse directe, administration SONUM, gestion des rôles (Référent / Gestionnaire), traçabilité, lien espace adhérent FEHAP. 14 tests Vitest passés.

This commit is contained in:
Manus
2026-04-15 12:14:03 -04:00
parent 48bfbc21c0
commit 522d2e3c87
26 changed files with 4638 additions and 198 deletions

View File

@@ -5,33 +5,31 @@ import { Route, Switch } from "wouter";
import ErrorBoundary from "./components/ErrorBoundary";
import { ThemeProvider } from "./contexts/ThemeContext";
import Home from "./pages/Home";
import MesEtablissements from "./pages/MesEtablissements";
import MesDemandes from "./pages/MesDemandes";
import FicheEtablissement from "./pages/FicheEtablissement";
import Admin from "./pages/Admin";
function Router() {
// make sure to consider if you need authentication for certain routes
return (
<Switch>
<Route path={"/"} component={Home} />
<Route path={"/404"} component={NotFound} />
{/* Final fallback route */}
<Route path="/" component={Home} />
<Route path="/mes-etablissements" component={MesEtablissements} />
<Route path="/mes-demandes" component={MesDemandes} />
<Route path="/etablissement/:id" component={FicheEtablissement} />
<Route path="/admin" component={Admin} />
<Route path="/404" component={NotFound} />
<Route component={NotFound} />
</Switch>
);
}
// NOTE: About Theme
// - First choose a default theme according to your design style (dark or light bg), than change color palette in index.css
// to keep consistent foreground/background color across components
// - If you want to make theme switchable, pass `switchable` ThemeProvider and use `useTheme` hook
function App() {
return (
<ErrorBoundary>
<ThemeProvider
defaultTheme="light"
// switchable
>
<ThemeProvider defaultTheme="light">
<TooltipProvider>
<Toaster />
<Toaster richColors position="top-right" />
<Router />
</TooltipProvider>
</ThemeProvider>

View File

@@ -0,0 +1,151 @@
import { trpc } from "@/lib/trpc";
import { AlertCircle, CheckCircle2, FileText, Shield } from "lucide-react";
import { useState } from "react";
interface CguModalProps {
onAccepted: () => void;
}
export default function CguModal({ onAccepted }: CguModalProps) {
const [checked, setChecked] = useState(false);
const acceptMutation = trpc.cgu.accept.useMutation({
onSuccess: () => onAccepted(),
});
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-card rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] flex flex-col border border-border">
{/* En-tête */}
<div className="px-8 py-6 border-b border-border">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center flex-shrink-0">
<FileText size={24} className="text-primary" />
</div>
<div>
<h2 className="text-xl font-bold text-foreground">Charte d'utilisation SONUM</h2>
<p className="text-sm text-muted-foreground mt-0.5">
Veuillez lire et accepter les conditions d'utilisation avant d'accéder à la plateforme.
</p>
</div>
</div>
</div>
{/* Contenu CGU */}
<div className="flex-1 overflow-y-auto px-8 py-6 space-y-5 text-sm text-foreground leading-relaxed">
<section>
<h3 className="font-semibold text-base text-primary mb-2 flex items-center gap-2">
<Shield size={16} /> Objet de la plateforme SONUM
</h3>
<p>
La plateforme SONUM (Solutions Numériques) est un outil collaboratif mis à disposition par la FEHAP
(Fédération des Établissements Hospitaliers et d'Aide à la Personne Privés Non Lucratifs) à destination
de ses établissements adhérents. Elle permet le référencement, le partage et la consultation des
solutions logicielles utilisées par les établissements membres.
</p>
</section>
<section>
<h3 className="font-semibold text-base text-primary mb-2">Données collectées et traitées</h3>
<p>
En utilisant SONUM, vous acceptez que les informations relatives aux logiciels utilisés par votre
établissement (nom de la solution, éditeur, état de déploiement, mode d'hébergement, etc.) soient
enregistrées et rendues accessibles aux autres référents numériques des établissements adhérents FEHAP,
selon les paramètres de visibilité que vous définissez.
</p>
<p className="mt-2">
Vos coordonnées professionnelles (nom, fonction, adresse email) pourront être partagées dans le cadre
des demandes de mise en relation entre établissements.
</p>
</section>
<section>
<h3 className="font-semibold text-base text-primary mb-2">Responsabilités de l'utilisateur</h3>
<p>
En tant que référent numérique, vous vous engagez à :
</p>
<ul className="mt-2 space-y-1 list-none">
{[
"Renseigner des informations exactes, à jour et complètes concernant les logiciels de vos établissements.",
"Mettre à jour régulièrement les informations saisies pour garantir la fiabilité des données.",
"Utiliser les fonctionnalités de mise en relation dans un cadre strictement professionnel.",
"Ne pas diffuser d'informations confidentielles ou sensibles via la plateforme.",
].map((item, i) => (
<li key={i} className="flex items-start gap-2">
<CheckCircle2 size={14} className="text-green-500 mt-0.5 flex-shrink-0" />
<span>{item}</span>
</li>
))}
</ul>
</section>
<section>
<h3 className="font-semibold text-base text-primary mb-2">Traçabilité et confidentialité</h3>
<p>
Chaque consultation d'une fiche logiciels est enregistrée à des fins de traçabilité. Ces données
sont accessibles uniquement au référent numérique responsable de l'établissement consulté et aux
gestionnaires SONUM de la FEHAP.
</p>
</section>
<section>
<h3 className="font-semibold text-base text-primary mb-2">Gestion des données personnelles</h3>
<p>
Conformément au Règlement Général sur la Protection des Données (RGPD), vous disposez d'un droit
d'accès, de rectification et de suppression de vos données personnelles. Pour exercer ces droits,
contactez : <a href="mailto:sonum@fehap.fr" className="text-primary hover:underline">sonum@fehap.fr</a>
</p>
</section>
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 flex items-start gap-3">
<AlertCircle size={18} className="text-amber-600 flex-shrink-0 mt-0.5" />
<p className="text-amber-800 text-xs">
L'accès à la plateforme SONUM est réservé aux référents numériques des établissements adhérents FEHAP.
Toute utilisation à des fins commerciales ou non professionnelles est strictement interdite.
</p>
</div>
</div>
{/* Pied de modale */}
<div className="px-8 py-5 border-t border-border bg-muted/30 rounded-b-2xl">
<label className="flex items-start gap-3 cursor-pointer mb-5">
<div className="relative mt-0.5">
<input
type="checkbox"
checked={checked}
onChange={(e) => setChecked(e.target.checked)}
className="sr-only"
/>
<div
className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-all ${
checked ? "bg-primary border-primary" : "border-border bg-white"
}`}
>
{checked && (
<svg width="10" height="8" viewBox="0 0 10 8" fill="none">
<path d="M1 4L3.5 6.5L9 1" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</div>
</div>
<span className="text-sm text-foreground leading-relaxed">
J'ai lu et j'accepte la charte d'utilisation de la plateforme SONUM, ainsi que les conditions
relatives au traitement de mes données personnelles.
</span>
</label>
<button
onClick={() => acceptMutation.mutate()}
disabled={!checked || acceptMutation.isPending}
className={`w-full py-3 px-6 rounded-lg font-semibold text-sm transition-all duration-200 ${
checked && !acceptMutation.isPending
? "bg-primary text-white hover:bg-primary/90 shadow-sm hover:shadow-md"
: "bg-muted text-muted-foreground cursor-not-allowed"
}`}
>
{acceptMutation.isPending ? "Enregistrement..." : "Accéder à SONUM"}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,96 @@
import { trpc } from "@/lib/trpc";
import { Building2, Mail, Send, X } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
interface ContactModalProps {
etablissementId: number;
etablissementNom: string;
onClose: () => void;
}
export default function ContactModal({ etablissementId, etablissementNom, onClose }: ContactModalProps) {
const [message, setMessage] = useState("");
const sendMutation = trpc.contact.envoyer.useMutation({
onSuccess: () => {
toast.success("Votre demande de contact a été envoyée avec succès.");
onClose();
},
onError: (err) => {
toast.error("Erreur lors de l'envoi : " + err.message);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!message.trim()) return;
sendMutation.mutate({ etablissementCibleId: etablissementId, message });
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-card rounded-2xl shadow-2xl max-w-lg w-full border border-border">
{/* En-tête */}
<div className="flex items-start justify-between px-6 py-5 border-b border-border">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center">
<Mail size={20} className="text-primary" />
</div>
<div>
<h2 className="text-base font-bold text-foreground">Demande de contact</h2>
<div className="flex items-center gap-1.5 text-sm text-muted-foreground mt-0.5">
<Building2 size={13} />
<span>{etablissementNom}</span>
</div>
</div>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
>
<X size={18} />
</button>
</div>
{/* Formulaire */}
<form onSubmit={handleSubmit} className="p-6">
<div className="mb-5">
<label className="block text-sm font-medium text-foreground mb-2">
Votre message <span className="text-destructive">*</span>
</label>
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Décrivez votre besoin ou votre question au référent numérique de cet établissement..."
rows={5}
className="w-full px-4 py-3 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-all resize-none"
required
/>
<p className="text-xs text-muted-foreground mt-1.5">
Le référent numérique de l'établissement et les gestionnaires SONUM recevront votre message.
</p>
</div>
<div className="flex gap-3">
<button
type="button"
onClick={onClose}
className="flex-1 py-2.5 px-4 rounded-lg text-sm font-medium border border-border text-foreground hover:bg-muted transition-colors"
>
Annuler
</button>
<button
type="submit"
disabled={!message.trim() || sendMutation.isPending}
className="flex-1 flex items-center justify-center gap-2 py-2.5 px-4 rounded-lg text-sm font-medium bg-primary text-white hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
>
<Send size={15} />
{sendMutation.isPending ? "Envoi..." : "Envoyer"}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,35 @@
import { ETATS_DEPLOIEMENT, MODES_HEBERGEMENT, MODES_FACTURATION, INTEROPERABILITE } from "../../../shared/referentiel";
type EtatDeploiement = "demarrage" | "en_cours" | "operationnel" | "en_remplacement";
const etatColors: Record<EtatDeploiement, string> = {
demarrage: "bg-orange-100 text-orange-700 border-orange-200",
en_cours: "bg-blue-100 text-blue-700 border-blue-200",
operationnel: "bg-green-100 text-green-700 border-green-200",
en_remplacement: "bg-red-100 text-red-700 border-red-200",
};
export function EtatBadge({ etat }: { etat: string }) {
const color = etatColors[etat as EtatDeploiement] ?? "bg-gray-100 text-gray-700 border-gray-200";
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border ${color}`}>
{ETATS_DEPLOIEMENT[etat] ?? etat}
</span>
);
}
export function HebergementBadge({ mode }: { mode: string }) {
return (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-slate-100 text-slate-600 border border-slate-200">
{MODES_HEBERGEMENT[mode] ?? mode}
</span>
);
}
export function FacturationBadge({ mode }: { mode: string }) {
return (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-50 text-purple-700 border border-purple-200">
{MODES_FACTURATION[mode] ?? mode}
</span>
);
}

View File

@@ -0,0 +1,417 @@
import { trpc } from "@/lib/trpc";
import { Check, ChevronRight, Plus, Search, X } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { ETATS_DEPLOIEMENT, MODES_HEBERGEMENT, MODES_FACTURATION, INTEROPERABILITE } from "../../../shared/referentiel";
interface Props {
etablissementId: number;
etablissementNom: string;
onClose: () => void;
onSuccess: () => void;
}
type Step = 1 | 2 | 3;
export default function RattacherSolutionModal({ etablissementId, etablissementNom, onClose, onSuccess }: Props) {
const [step, setStep] = useState<Step>(1);
const [searchSolution, setSearchSolution] = useState("");
const [selectedSolution, setSelectedSolution] = useState<{ id: number; nom: string; editeurNom: string } | null>(null);
const [newSolutionNom, setNewSolutionNom] = useState("");
const [newEditeurNom, setNewEditeurNom] = useState("");
const [selectedEditeurId, setSelectedEditeurId] = useState<number | null>(null);
const [showNewSolution, setShowNewSolution] = useState(false);
const [metadata, setMetadata] = useState({
etatDeploiement: "operationnel" as "demarrage" | "en_cours" | "operationnel" | "en_remplacement",
modeHebergement: "" as string,
modeFacturation: "" as string,
interoperabilite: "" as string,
versionMajeure: "",
commentaire: "",
});
const { user } = trpc.auth.me.useQuery().data ? { user: trpc.auth.me.useQuery().data } : { user: null };
const solutionsQuery = trpc.referentiel.solutions.useQuery(
{ search: searchSolution.length >= 2 ? searchSolution : undefined },
{ enabled: step === 1 }
);
const editeursQuery = trpc.referentiel.editeurs.useQuery();
const blocsQuery = trpc.referentiel.blocsFonctionnels.useQuery();
const createEditeurMutation = trpc.referentiel.createEditeur.useMutation();
const createSolutionMutation = trpc.referentiel.createSolution.useMutation();
const upsertLogicielMutation = trpc.logiciels.upsert.useMutation({
onSuccess: () => {
toast.success("Solution rattachée avec succès !");
onSuccess();
},
onError: (err) => toast.error("Erreur : " + err.message),
});
const handleSelectSolution = (sol: any) => {
setSelectedSolution({ id: sol.id, nom: sol.nom, editeurNom: sol.editeurNom ?? "" });
setStep(2);
};
const handleCreateAndContinue = async () => {
if (!newSolutionNom.trim()) return;
let editeurId = selectedEditeurId;
if (!editeurId && newEditeurNom.trim()) {
const result = await createEditeurMutation.mutateAsync({ nom: newEditeurNom });
editeurId = null; // Will be validated by FEHAP
}
if (!editeurId) {
toast.error("Veuillez sélectionner ou saisir un éditeur.");
return;
}
const sol = await createSolutionMutation.mutateAsync({ nom: newSolutionNom, editeurId });
const editeurNom = editeursQuery.data?.find(e => e.id === editeurId)?.nom ?? newEditeurNom;
setSelectedSolution({ id: Number(sol), nom: newSolutionNom, editeurNom });
setStep(2);
};
const handleSubmit = () => {
if (!selectedSolution) return;
upsertLogicielMutation.mutate({
etablissementId,
solutionId: selectedSolution.id,
etatDeploiement: metadata.etatDeploiement,
modeHebergement: (metadata.modeHebergement || null) as any,
modeFacturation: (metadata.modeFacturation || null) as any,
interoperabilite: (metadata.interoperabilite || null) as any,
versionMajeure: metadata.versionMajeure || null,
commentaire: metadata.commentaire || null,
});
};
const steps = [
{ n: 1, label: "Solution" },
{ n: 2, label: "Métadonnées" },
{ n: 3, label: "Validation" },
];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-card rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] flex flex-col border border-border">
{/* En-tête */}
<div className="flex items-center justify-between px-6 py-5 border-b border-border">
<div>
<h2 className="text-base font-bold text-foreground">Rattacher une solution</h2>
<p className="text-sm text-muted-foreground mt-0.5">{etablissementNom}</p>
</div>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-muted transition-colors text-muted-foreground">
<X size={18} />
</button>
</div>
{/* Indicateur d'étapes */}
<div className="flex items-center px-6 py-4 border-b border-border bg-muted/20">
{steps.map((s, i) => (
<div key={s.n} className="flex items-center">
<div className={`flex items-center gap-2 ${step === s.n ? "text-primary" : step > s.n ? "text-green-600" : "text-muted-foreground"}`}>
<div className={`w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold border-2 transition-all ${
step === s.n ? "border-primary bg-primary text-white" :
step > s.n ? "border-green-500 bg-green-500 text-white" :
"border-border bg-background"
}`}>
{step > s.n ? <Check size={13} /> : s.n}
</div>
<span className="text-xs font-medium hidden sm:block">{s.label}</span>
</div>
{i < steps.length - 1 && (
<ChevronRight size={16} className="mx-3 text-muted-foreground/50" />
)}
</div>
))}
</div>
{/* Contenu */}
<div className="flex-1 overflow-y-auto p-6">
{/* Étape 1 : Recherche de solution */}
{step === 1 && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-foreground mb-2">
Rechercher une solution existante
</label>
<div className="relative">
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input
type="text"
placeholder="Nom du logiciel ou de l'éditeur..."
value={searchSolution}
onChange={(e) => setSearchSolution(e.target.value)}
className="w-full pl-9 pr-4 py-2.5 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary"
/>
</div>
</div>
{/* Résultats */}
{solutionsQuery.data && solutionsQuery.data.length > 0 && (
<div className="border border-border rounded-lg overflow-hidden max-h-52 overflow-y-auto">
{solutionsQuery.data.map((sol) => (
<button
key={sol.id}
onClick={() => handleSelectSolution(sol)}
className="w-full flex items-center justify-between px-4 py-3 hover:bg-muted/50 transition-colors text-left border-b border-border last:border-0"
>
<div>
<div className="font-medium text-sm text-foreground">{sol.nom}</div>
<div className="text-xs text-muted-foreground">{sol.editeurNom}</div>
</div>
{sol.blocFonctionnelNom && (
<span className="text-xs bg-secondary text-secondary-foreground px-2 py-0.5 rounded border border-border ml-2 flex-shrink-0">
{sol.blocFonctionnelNom}
</span>
)}
</button>
))}
</div>
)}
{/* Ajouter une nouvelle solution */}
<div className="border-t border-border pt-4">
<button
onClick={() => setShowNewSolution(!showNewSolution)}
className="flex items-center gap-2 text-sm font-medium text-primary hover:text-primary/80 transition-colors"
>
<Plus size={15} />
La solution n'existe pas ? L'ajouter
</button>
{showNewSolution && (
<div className="mt-4 space-y-3 p-4 bg-muted/30 rounded-lg border border-border">
<p className="text-xs text-muted-foreground">
La solution sera ajoutée au référentiel FEHAP après validation.
</p>
<div>
<label className="block text-xs font-medium text-foreground mb-1">Nom de la solution *</label>
<input
type="text"
value={newSolutionNom}
onChange={(e) => setNewSolutionNom(e.target.value)}
placeholder="Ex : Mon Logiciel RH"
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30"
/>
</div>
<div>
<label className="block text-xs font-medium text-foreground mb-1">Éditeur *</label>
<select
value={selectedEditeurId ?? ""}
onChange={(e) => setSelectedEditeurId(e.target.value ? Number(e.target.value) : null)}
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30"
>
<option value="">Sélectionner un éditeur</option>
{editeursQuery.data?.map((e) => (
<option key={e.id} value={e.id}>{e.nom}</option>
))}
</select>
</div>
{!selectedEditeurId && (
<div>
<label className="block text-xs font-medium text-foreground mb-1">Ou saisir un nouvel éditeur</label>
<input
type="text"
value={newEditeurNom}
onChange={(e) => setNewEditeurNom(e.target.value)}
placeholder="Nom de l'éditeur"
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30"
/>
</div>
)}
<button
onClick={handleCreateAndContinue}
disabled={!newSolutionNom.trim() || (!selectedEditeurId && !newEditeurNom.trim())}
className="w-full py-2 px-4 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
>
Continuer avec cette solution
</button>
</div>
)}
</div>
</div>
)}
{/* Étape 2 : Métadonnées */}
{step === 2 && selectedSolution && (
<div className="space-y-5">
<div className="bg-muted/30 rounded-lg p-3 border border-border">
<p className="text-xs text-muted-foreground">Solution sélectionnée</p>
<p className="font-semibold text-foreground">{selectedSolution.nom}</p>
<p className="text-sm text-muted-foreground">{selectedSolution.editeurNom}</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* État de déploiement - obligatoire */}
<div className="sm:col-span-2">
<label className="block text-sm font-medium text-foreground mb-1.5">
État de déploiement <span className="text-destructive">*</span>
</label>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{Object.entries(ETATS_DEPLOIEMENT).map(([val, label]) => (
<button
key={val}
onClick={() => setMetadata((m) => ({ ...m, etatDeploiement: val as any }))}
className={`py-2 px-3 rounded-lg text-xs font-medium border transition-all ${
metadata.etatDeploiement === val
? "bg-primary text-white border-primary shadow-sm"
: "bg-background border-border text-foreground hover:bg-muted"
}`}
>
{label}
</button>
))}
</div>
</div>
{/* Mode d'hébergement */}
<div>
<label className="block text-xs font-medium text-foreground mb-1.5">Mode d'hébergement</label>
<select
value={metadata.modeHebergement}
onChange={(e) => setMetadata((m) => ({ ...m, modeHebergement: e.target.value }))}
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30"
>
<option value="">Non renseigné</option>
{Object.entries(MODES_HEBERGEMENT).map(([val, label]) => (
<option key={val} value={val}>{label}</option>
))}
</select>
</div>
{/* Mode de facturation */}
<div>
<label className="block text-xs font-medium text-foreground mb-1.5">Mode de facturation</label>
<select
value={metadata.modeFacturation}
onChange={(e) => setMetadata((m) => ({ ...m, modeFacturation: e.target.value }))}
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30"
>
<option value="">Non renseigné</option>
{Object.entries(MODES_FACTURATION).map(([val, label]) => (
<option key={val} value={val}>{label}</option>
))}
</select>
</div>
{/* Interopérabilité */}
<div>
<label className="block text-xs font-medium text-foreground mb-1.5">Interopérabilité</label>
<select
value={metadata.interoperabilite}
onChange={(e) => setMetadata((m) => ({ ...m, interoperabilite: e.target.value }))}
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30"
>
<option value="">Non renseigné</option>
{Object.entries(INTEROPERABILITE).map(([val, label]) => (
<option key={val} value={val}>{label}</option>
))}
</select>
</div>
{/* Version majeure */}
<div>
<label className="block text-xs font-medium text-foreground mb-1.5">Version majeure</label>
<input
type="text"
value={metadata.versionMajeure}
onChange={(e) => setMetadata((m) => ({ ...m, versionMajeure: e.target.value }))}
placeholder="Ex : 5.2"
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30"
/>
</div>
{/* Commentaire */}
<div className="sm:col-span-2">
<label className="block text-xs font-medium text-foreground mb-1.5">Commentaire libre</label>
<textarea
value={metadata.commentaire}
onChange={(e) => setMetadata((m) => ({ ...m, commentaire: e.target.value }))}
placeholder="Toute précision utile sur cette solution..."
rows={3}
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 resize-none"
/>
</div>
</div>
</div>
)}
{/* Étape 3 : Validation */}
{step === 3 && selectedSolution && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">Vérifiez les informations avant de valider.</p>
<div className="bg-muted/30 rounded-xl border border-border p-5 space-y-3">
<div className="flex justify-between items-center py-2 border-b border-border">
<span className="text-sm text-muted-foreground">Solution</span>
<span className="text-sm font-semibold text-foreground">{selectedSolution.nom}</span>
</div>
<div className="flex justify-between items-center py-2 border-b border-border">
<span className="text-sm text-muted-foreground">Éditeur</span>
<span className="text-sm text-foreground">{selectedSolution.editeurNom}</span>
</div>
<div className="flex justify-between items-center py-2 border-b border-border">
<span className="text-sm text-muted-foreground">État de déploiement</span>
<span className="text-sm font-medium text-foreground">{ETATS_DEPLOIEMENT[metadata.etatDeploiement]}</span>
</div>
{metadata.modeHebergement && (
<div className="flex justify-between items-center py-2 border-b border-border">
<span className="text-sm text-muted-foreground">Hébergement</span>
<span className="text-sm text-foreground">{MODES_HEBERGEMENT[metadata.modeHebergement]}</span>
</div>
)}
{metadata.modeFacturation && (
<div className="flex justify-between items-center py-2 border-b border-border">
<span className="text-sm text-muted-foreground">Facturation</span>
<span className="text-sm text-foreground">{MODES_FACTURATION[metadata.modeFacturation]}</span>
</div>
)}
{metadata.versionMajeure && (
<div className="flex justify-between items-center py-2 border-b border-border">
<span className="text-sm text-muted-foreground">Version</span>
<span className="text-sm text-foreground">{metadata.versionMajeure}</span>
</div>
)}
<div className="flex justify-between items-center py-2">
<span className="text-sm text-muted-foreground">Établissement</span>
<span className="text-sm font-medium text-foreground">{etablissementNom}</span>
</div>
</div>
</div>
)}
</div>
{/* Pied de modale */}
<div className="px-6 py-4 border-t border-border bg-muted/20 flex justify-between">
<button
onClick={() => step > 1 ? setStep((s) => (s - 1) as Step) : onClose()}
className="px-4 py-2 text-sm font-medium border border-border rounded-lg text-foreground hover:bg-muted transition-colors"
>
{step === 1 ? "Annuler" : "Retour"}
</button>
{step < 3 ? (
<button
onClick={() => step === 1 && selectedSolution ? setStep(2) : step === 2 ? setStep(3) : null}
disabled={step === 1 ? !selectedSolution : false}
className="px-5 py-2 text-sm font-medium bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
>
Continuer
</button>
) : (
<button
onClick={handleSubmit}
disabled={upsertLogicielMutation.isPending}
className="flex items-center gap-2 px-5 py-2 text-sm font-medium bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors shadow-sm disabled:opacity-50"
>
<Check size={15} />
{upsertLogicielMutation.isPending ? "Enregistrement..." : "Valider"}
</button>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,240 @@
import { useAuth } from "@/_core/hooks/useAuth";
import { getLoginUrl } from "@/const";
import { trpc } from "@/lib/trpc";
import {
Bell,
Building2,
ChevronRight,
ExternalLink,
LayoutDashboard,
LogOut,
Mail,
Menu,
Search,
Settings,
Shield,
Users,
X,
} from "lucide-react";
import { useState } from "react";
import { Link, useLocation } from "wouter";
interface NavItem {
label: string;
href: string;
icon: React.ReactNode;
adminOnly?: boolean;
}
const navItems: NavItem[] = [
{ label: "Moteur de recherche", href: "/", icon: <Search size={18} /> },
{ label: "Mes Établissements", href: "/mes-etablissements", icon: <Building2 size={18} /> },
{ label: "Mes Demandes de Contact", href: "/mes-demandes", icon: <Mail size={18} /> },
];
const adminNavItems: NavItem[] = [
{ label: "Administration", href: "/admin", icon: <Shield size={18} />, adminOnly: true },
];
export default function SonumLayout({ children }: { children: React.ReactNode }) {
const { user, isAuthenticated, logout } = useAuth();
const [location] = useLocation();
const [sidebarOpen, setSidebarOpen] = useState(false);
const _cguQuery = trpc.cgu.status.useQuery(undefined, { enabled: isAuthenticated });
if (!isAuthenticated) {
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center max-w-md mx-auto px-6">
<div className="mb-8">
<div className="inline-flex items-center gap-3 mb-6">
<div className="w-12 h-12 rounded-xl bg-primary flex items-center justify-center">
<Search size={24} className="text-white" />
</div>
<div className="text-left">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-widest">FEHAP</div>
<div className="text-xl font-bold text-primary" style={{ fontFamily: "'Playfair Display', serif" }}>SONUM</div>
</div>
</div>
<h1 className="text-2xl font-bold text-foreground mb-2">Cartographie des Solutions Numériques</h1>
<p className="text-muted-foreground text-sm leading-relaxed">
Plateforme de référencement et de partage des logiciels utilisés par les établissements adhérents FEHAP.
</p>
</div>
<a
href={getLoginUrl()}
className="inline-flex items-center gap-2 px-6 py-3 bg-primary text-white rounded-lg font-medium hover:bg-primary/90 transition-colors shadow-sm"
>
Se connecter via l'espace adhérent FEHAP
<ExternalLink size={16} />
</a>
<p className="mt-4 text-xs text-muted-foreground">
Accès réservé aux adhérents FEHAP
</p>
</div>
</div>
);
}
const isGestionnaire = user?.sonumRole === "gestionnaire" || user?.role === "admin";
return (
<div className="min-h-screen flex bg-background">
{/* Overlay mobile */}
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/40 z-20 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Sidebar */}
<aside
className={`fixed top-0 left-0 h-full w-64 z-30 flex flex-col transition-transform duration-300 lg:translate-x-0 lg:static lg:z-auto ${
sidebarOpen ? "translate-x-0" : "-translate-x-full"
}`}
style={{ background: "var(--sidebar)" }}
>
{/* Logo */}
<div className="flex items-center justify-between px-5 py-5 border-b border-sidebar-border">
<Link href="/" className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-white/15 flex items-center justify-center">
<Search size={18} className="text-white" />
</div>
<div>
<div className="text-[10px] font-semibold text-white/50 uppercase tracking-widest">FEHAP</div>
<div className="text-base font-bold text-white leading-tight" style={{ fontFamily: "'Playfair Display', serif" }}>SONUM</div>
</div>
</Link>
<button
className="lg:hidden text-white/60 hover:text-white transition-colors"
onClick={() => setSidebarOpen(false)}
>
<X size={20} />
</button>
</div>
{/* Navigation */}
<nav className="flex-1 px-3 py-4 overflow-y-auto">
<div className="space-y-1">
{navItems.map((item) => {
const isActive = location === item.href;
return (
<Link
key={item.href}
href={item.href}
onClick={() => setSidebarOpen(false)}
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-150 ${
isActive
? "bg-white/20 text-white shadow-sm"
: "text-white/70 hover:bg-white/10 hover:text-white"
}`}
>
<span className={isActive ? "text-white" : "text-white/60"}>{item.icon}</span>
{item.label}
{isActive && <ChevronRight size={14} className="ml-auto text-white/60" />}
</Link>
);
})}
</div>
{isGestionnaire && (
<div className="mt-6">
<div className="px-3 mb-2">
<span className="text-[10px] font-semibold text-white/40 uppercase tracking-widest">Administration</span>
</div>
<div className="space-y-1">
{adminNavItems.map((item) => {
const isActive = location === item.href;
return (
<Link
key={item.href}
href={item.href}
onClick={() => setSidebarOpen(false)}
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-150 ${
isActive
? "bg-white/20 text-white shadow-sm"
: "text-white/70 hover:bg-white/10 hover:text-white"
}`}
>
<span className={isActive ? "text-white" : "text-white/60"}>{item.icon}</span>
{item.label}
</Link>
);
})}
</div>
</div>
)}
</nav>
{/* Footer sidebar */}
<div className="px-3 py-4 border-t border-sidebar-border space-y-2">
<a
href="https://www.fehap.fr/espace-adherent"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-white/70 hover:bg-white/10 hover:text-white transition-all duration-150"
>
<ExternalLink size={16} className="text-white/60" />
Espace adhérent FEHAP
</a>
<button
onClick={() => logout()}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-white/70 hover:bg-white/10 hover:text-white transition-all duration-150"
>
<LogOut size={16} className="text-white/60" />
Se déconnecter
</button>
</div>
</aside>
{/* Contenu principal */}
<div className="flex-1 flex flex-col min-w-0">
{/* Header */}
<header className="sticky top-0 z-10 bg-card border-b border-border px-4 lg:px-6 h-14 flex items-center justify-between shadow-sm">
<button
className="lg:hidden p-2 rounded-lg hover:bg-muted transition-colors"
onClick={() => setSidebarOpen(true)}
>
<Menu size={20} className="text-foreground" />
</button>
<div className="hidden lg:flex items-center gap-2 text-sm text-muted-foreground">
<span className="font-medium text-foreground">
{navItems.find((n) => n.href === location)?.label ?? "SONUM"}
</span>
</div>
<div className="flex items-center gap-3 ml-auto">
{/* Badge rôle */}
<span
className={`hidden sm:inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${
isGestionnaire
? "bg-accent/10 text-accent border border-accent/20"
: "bg-primary/10 text-primary border border-primary/20"
}`}
>
{isGestionnaire ? <Shield size={11} /> : <Users size={11} />}
{isGestionnaire ? "Gestionnaire SONUM" : "Référent numérique"}
</span>
{/* Avatar */}
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-primary flex items-center justify-center text-white text-xs font-semibold">
{user?.name?.charAt(0)?.toUpperCase() ?? "U"}
</div>
<span className="hidden md:block text-sm font-medium text-foreground max-w-32 truncate">
{user?.name}
</span>
</div>
</div>
</header>
{/* Page content */}
<main className="flex-1 overflow-auto">
{children}
</main>
</div>
</div>
);
}

View File

@@ -1,5 +1,6 @@
@import "tailwindcss";
@import "tw-animate-css";
@import url('https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,300..700;1,14..32,300..700&family=Playfair+Display:wght@600;700&display=swap');
@custom-variant dark (&:is(.dark *));
@@ -43,39 +44,41 @@
}
:root {
--primary: var(--color-blue-700);
--primary-foreground: var(--color-blue-50);
--sidebar-primary: var(--color-blue-600);
--sidebar-primary-foreground: var(--color-blue-50);
--chart-1: var(--color-blue-300);
--chart-2: var(--color-blue-500);
--chart-3: var(--color-blue-600);
--chart-4: var(--color-blue-700);
--chart-5: var(--color-blue-800);
--radius: 0.65rem;
--background: oklch(1 0 0);
--foreground: oklch(0.235 0.015 65);
/* Bleu marine FEHAP */
--primary: oklch(0.30 0.12 255);
--primary-foreground: oklch(0.98 0 0);
/* Rouge FEHAP */
--accent: oklch(0.52 0.20 20);
--accent-foreground: oklch(0.98 0 0);
--sidebar-primary: oklch(0.30 0.12 255);
--sidebar-primary-foreground: oklch(0.98 0 0);
--chart-1: oklch(0.55 0.14 255);
--chart-2: oklch(0.45 0.14 255);
--chart-3: oklch(0.35 0.12 255);
--chart-4: oklch(0.52 0.20 20);
--chart-5: oklch(0.60 0.15 145);
--radius: 0.5rem;
--background: oklch(0.975 0.004 240);
--foreground: oklch(0.18 0.02 250);
--card: oklch(1 0 0);
--card-foreground: oklch(0.235 0.015 65);
--card-foreground: oklch(0.18 0.02 250);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.235 0.015 65);
--secondary: oklch(0.98 0.001 286.375);
--secondary-foreground: oklch(0.4 0.015 65);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.141 0.005 285.823);
--popover-foreground: oklch(0.18 0.02 250);
--secondary: oklch(0.93 0.04 240);
--secondary-foreground: oklch(0.30 0.12 255);
--muted: oklch(0.95 0.01 240);
--muted-foreground: oklch(0.52 0.02 250);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.623 0.214 259.815);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.235 0.015 65);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.141 0.005 285.823);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.623 0.214 259.815);
--border: oklch(0.88 0.01 240);
--input: oklch(0.88 0.01 240);
--ring: oklch(0.30 0.12 255);
--sidebar: oklch(0.25 0.10 255);
--sidebar-foreground: oklch(0.95 0.01 240);
--sidebar-accent: oklch(0.35 0.12 255);
--sidebar-accent-foreground: oklch(0.98 0 0);
--sidebar-border: oklch(0.38 0.10 255);
--sidebar-ring: oklch(0.55 0.14 255);
}
.dark {
@@ -119,6 +122,13 @@
}
body {
@apply bg-background text-foreground;
font-family: 'Inter', system-ui, sans-serif;
font-feature-settings: "cv11", "ss01";
}
h1, h2, h3 {
font-family: 'Playfair Display', Georgia, serif;
font-weight: 600;
letter-spacing: -0.02em;
}
button:not(:disabled),
[role="button"]:not([aria-disabled="true"]),

283
client/src/pages/Admin.tsx Normal file
View File

@@ -0,0 +1,283 @@
import { useAuth } from "@/_core/hooks/useAuth";
import SonumLayout from "@/components/SonumLayout";
import { trpc } from "@/lib/trpc";
import {
Building2,
Check,
ChevronDown,
Plus,
Shield,
User,
Users,
X,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { useLocation } from "wouter";
export default function Admin() {
const { user } = useAuth();
const [, navigate] = useLocation();
const isGestionnaire = user?.sonumRole === "gestionnaire" || user?.role === "admin";
if (!isGestionnaire) {
return (
<SonumLayout>
<div className="p-8 text-center">
<Shield size={48} className="mx-auto text-muted-foreground/30 mb-4" />
<p className="text-muted-foreground font-medium">Accès réservé aux gestionnaires SONUM</p>
</div>
</SonumLayout>
);
}
return (
<SonumLayout>
<div className="p-6 lg:p-8 max-w-6xl mx-auto">
{/* En-tête */}
<div className="mb-8">
<h1 className="text-2xl font-bold text-foreground mb-1">Administration SONUM</h1>
<p className="text-muted-foreground text-sm">
Gestion des utilisateurs, des établissements et du référentiel
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Gestion des utilisateurs */}
<div className="lg:col-span-2">
<UsersPanel />
</div>
{/* Gestion des établissements */}
<div className="lg:col-span-2">
<EtablissementsPanel />
</div>
</div>
</div>
</SonumLayout>
);
}
function UsersPanel() {
const usersQuery = trpc.admin.users.useQuery();
const updateRoleMutation = trpc.admin.updateRole.useMutation({
onSuccess: () => {
toast.success("Rôle mis à jour");
usersQuery.refetch();
},
});
return (
<div className="bg-card rounded-xl border border-border shadow-sm overflow-hidden">
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
<div className="flex items-center gap-2">
<Users size={18} className="text-primary" />
<h2 className="font-semibold text-foreground">Utilisateurs</h2>
<span className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full">
{usersQuery.data?.length ?? 0}
</span>
</div>
</div>
{usersQuery.isLoading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-2 border-primary border-t-transparent" />
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/20">
<th className="text-left px-5 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider">Utilisateur</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider hidden md:table-cell">Email</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider">Rôle SONUM</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider hidden lg:table-cell">Dernière connexion</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{usersQuery.data?.map((u) => (
<tr key={u.id} className="hover:bg-muted/20 transition-colors">
<td className="px-5 py-3.5">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-full bg-primary/10 flex items-center justify-center text-xs font-semibold text-primary">
{u.name?.charAt(0)?.toUpperCase() ?? "U"}
</div>
<span className="font-medium text-foreground">{u.name ?? "—"}</span>
</div>
</td>
<td className="px-4 py-3.5 hidden md:table-cell text-muted-foreground text-xs">{u.email ?? "—"}</td>
<td className="px-4 py-3.5">
<select
value={u.sonumRole ?? "referent"}
onChange={(e) =>
updateRoleMutation.mutate({
userId: u.id,
sonumRole: e.target.value as "referent" | "gestionnaire",
})
}
className={`text-xs px-2.5 py-1.5 rounded-lg border font-medium focus:outline-none focus:ring-2 focus:ring-primary/30 transition-all ${
u.sonumRole === "gestionnaire"
? "bg-accent/10 text-accent border-accent/20"
: "bg-primary/10 text-primary border-primary/20"
}`}
>
<option value="referent">Référent numérique</option>
<option value="gestionnaire">Gestionnaire SONUM</option>
</select>
</td>
<td className="px-4 py-3.5 hidden lg:table-cell text-muted-foreground text-xs">
{u.lastSignedIn
? new Date(u.lastSignedIn).toLocaleDateString("fr-FR", { day: "2-digit", month: "short", year: "numeric" })
: "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
function EtablissementsPanel() {
const etablissementsQuery = trpc.etablissements.all.useQuery();
const [showCreate, setShowCreate] = useState(false);
const [form, setForm] = useState({
nom: "",
finess: "",
region: "",
typeActivite: "",
tailleEffectifs: "",
});
const createMutation = trpc.etablissements.create.useMutation({
onSuccess: () => {
toast.success("Établissement créé");
setShowCreate(false);
setForm({ nom: "", finess: "", region: "", typeActivite: "", tailleEffectifs: "" });
etablissementsQuery.refetch();
},
});
return (
<div className="bg-card rounded-xl border border-border shadow-sm overflow-hidden">
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
<div className="flex items-center gap-2">
<Building2 size={18} className="text-primary" />
<h2 className="font-semibold text-foreground">Établissements</h2>
<span className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full">
{etablissementsQuery.data?.length ?? 0}
</span>
</div>
<button
onClick={() => setShowCreate(!showCreate)}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors shadow-sm"
>
<Plus size={13} />
Ajouter
</button>
</div>
{/* Formulaire de création */}
{showCreate && (
<div className="px-5 py-4 border-b border-border bg-muted/20">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-3">
<div>
<label className="block text-xs font-medium text-foreground mb-1">Nom *</label>
<input
value={form.nom}
onChange={(e) => setForm((f) => ({ ...f, nom: e.target.value }))}
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30"
placeholder="Nom de l'établissement"
/>
</div>
<div>
<label className="block text-xs font-medium text-foreground mb-1">FINESS</label>
<input
value={form.finess}
onChange={(e) => setForm((f) => ({ ...f, finess: e.target.value }))}
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30"
placeholder="N° FINESS"
/>
</div>
<div>
<label className="block text-xs font-medium text-foreground mb-1">Région</label>
<input
value={form.region}
onChange={(e) => setForm((f) => ({ ...f, region: e.target.value }))}
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30"
placeholder="Région"
/>
</div>
<div>
<label className="block text-xs font-medium text-foreground mb-1">Type d'activité</label>
<input
value={form.typeActivite}
onChange={(e) => setForm((f) => ({ ...f, typeActivite: e.target.value }))}
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30"
placeholder="MCO, EHPAD..."
/>
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => setShowCreate(false)}
className="px-3 py-1.5 text-xs font-medium border border-border rounded-lg text-foreground hover:bg-muted"
>
Annuler
</button>
<button
onClick={() => createMutation.mutate(form)}
disabled={!form.nom.trim() || createMutation.isPending}
className="flex items-center gap-1.5 px-4 py-1.5 text-xs font-medium bg-primary text-white rounded-lg hover:bg-primary/90 disabled:opacity-50 shadow-sm"
>
<Check size={13} />
{createMutation.isPending ? "Création..." : "Créer"}
</button>
</div>
</div>
)}
{etablissementsQuery.isLoading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-2 border-primary border-t-transparent" />
</div>
) : (
<div className="overflow-x-auto max-h-96 overflow-y-auto">
<table className="w-full text-sm">
<thead className="sticky top-0">
<tr className="border-b border-border bg-muted/30">
<th className="text-left px-5 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider">Établissement</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider hidden md:table-cell">Région</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider hidden lg:table-cell">Type</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider hidden lg:table-cell">Référent</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{etablissementsQuery.data?.map((etab) => (
<tr key={etab.id} className="hover:bg-muted/20 transition-colors">
<td className="px-5 py-3">
<div className="font-medium text-foreground text-sm">{etab.nom}</div>
{etab.finess && <div className="text-xs text-muted-foreground">FINESS : {etab.finess}</div>}
</td>
<td className="px-4 py-3 hidden md:table-cell text-muted-foreground text-sm">{etab.region ?? "—"}</td>
<td className="px-4 py-3 hidden lg:table-cell">
{etab.typeActivite ? (
<span className="text-xs bg-secondary text-secondary-foreground px-2 py-0.5 rounded border border-border">
{etab.typeActivite}
</span>
) : <span className="text-muted-foreground text-xs"></span>}
</td>
<td className="px-4 py-3 hidden lg:table-cell text-muted-foreground text-xs">
{etab.referentId ? `Réf. #${etab.referentId}` : <span className="italic">Non assigné</span>}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,240 @@
import { useAuth } from "@/_core/hooks/useAuth";
import { EtatBadge, HebergementBadge, FacturationBadge } from "@/components/EtatBadge";
import SonumLayout from "@/components/SonumLayout";
import ContactModal from "@/components/ContactModal";
import { trpc } from "@/lib/trpc";
import {
ArrowLeft,
Building2,
Eye,
Mail,
MapPin,
Phone,
Server,
Tag,
Users,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useLocation } from "wouter";
import { INTEROPERABILITE } from "../../../shared/referentiel";
interface Props {
params: { id: string };
}
export default function FicheEtablissement({ params }: Props) {
const id = Number(params.id);
const { user } = useAuth();
const [, navigate] = useLocation();
const [showContact, setShowContact] = useState(false);
const etabQuery = trpc.etablissements.byId.useQuery({ id }, { enabled: !!id });
const logicielsQuery = trpc.logiciels.byEtablissement.useQuery({ etablissementId: id }, { enabled: !!id });
const recordConsultation = trpc.tracabilite.enregistrerConsultation.useMutation();
const isGestionnaire = user?.sonumRole === "gestionnaire" || user?.role === "admin";
const isReferent = etabQuery.data?.referentId === user?.id;
const canSeeCounter = isReferent || isGestionnaire;
const compteurQuery = trpc.tracabilite.compteur.useQuery(
{ etablissementId: id },
{ enabled: !!id && canSeeCounter }
);
useEffect(() => {
if (id) {
recordConsultation.mutate({ etablissementId: id });
}
}, [id]);
if (etabQuery.isLoading) {
return (
<SonumLayout>
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary border-t-transparent" />
</div>
</SonumLayout>
);
}
if (!etabQuery.data) {
return (
<SonumLayout>
<div className="p-8 text-center text-muted-foreground">Établissement introuvable.</div>
</SonumLayout>
);
}
const etab = etabQuery.data;
// Grouper les logiciels par bloc fonctionnel
const logicielsByBloc = (logicielsQuery.data ?? []).reduce((acc, l) => {
const bloc = l.blocFonctionnelNom ?? "Autres";
if (!acc[bloc]) acc[bloc] = [];
acc[bloc].push(l);
return acc;
}, {} as Record<string, typeof logicielsQuery.data>);
return (
<SonumLayout>
<div className="p-6 lg:p-8 max-w-5xl mx-auto">
{/* Retour */}
<button
onClick={() => navigate(-1 as any)}
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors mb-6"
>
<ArrowLeft size={16} />
Retour
</button>
{/* En-tête fiche */}
<div className="bg-card rounded-xl border border-border shadow-sm p-6 mb-6">
<div className="flex items-start justify-between flex-wrap gap-4">
<div className="flex items-start gap-4">
<div className="w-14 h-14 rounded-xl bg-primary/10 flex items-center justify-center flex-shrink-0">
<Building2 size={28} className="text-primary" />
</div>
<div>
<h1 className="text-xl font-bold text-foreground mb-1">{etab.nom}</h1>
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
{etab.finess && (
<span className="flex items-center gap-1">
<Tag size={13} />
FINESS : {etab.finess}
</span>
)}
{etab.region && (
<span className="flex items-center gap-1">
<MapPin size={13} />
{etab.region}
</span>
)}
{etab.typeActivite && (
<span className="bg-secondary text-secondary-foreground px-2.5 py-0.5 rounded-full text-xs font-medium border border-border">
{etab.typeActivite}
</span>
)}
{etab.tailleEffectifs && (
<span className="flex items-center gap-1">
<Users size={13} />
{etab.tailleEffectifs}
</span>
)}
</div>
</div>
</div>
<div className="flex items-center gap-3">
{/* Compteur de consultation (visible référent + gestionnaire) */}
{canSeeCounter && compteurQuery.data !== undefined && (
<div className="flex items-center gap-2 px-3 py-2 bg-muted rounded-lg border border-border text-sm">
<Eye size={15} className="text-muted-foreground" />
<span className="text-muted-foreground">Consultations :</span>
<span className="font-bold text-foreground">{compteurQuery.data.count}</span>
</div>
)}
{/* Bouton de contact */}
{etab.accepteMiseEnRelation && (
<button
onClick={() => setShowContact(true)}
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary/90 transition-colors shadow-sm"
>
<Mail size={15} />
Prendre contact
</button>
)}
</div>
</div>
</div>
{/* Logiciels par bloc fonctionnel */}
<div>
<h2 className="text-lg font-bold text-foreground mb-4">Solutions numériques</h2>
{logicielsQuery.isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-7 w-7 border-2 border-primary border-t-transparent" />
</div>
) : Object.keys(logicielsByBloc).length === 0 ? (
<div className="text-center py-12 bg-card rounded-xl border border-border">
<Server size={40} className="mx-auto text-muted-foreground/30 mb-3" />
<p className="text-muted-foreground">Aucun logiciel renseigné pour cet établissement.</p>
</div>
) : (
<div className="space-y-4">
{Object.entries(logicielsByBloc).map(([bloc, logiciels]) => (
<div key={bloc} className="bg-card rounded-xl border border-border shadow-sm overflow-hidden">
{/* En-tête bloc */}
<div className="px-5 py-3.5 bg-muted/30 border-b border-border">
<h3 className="text-sm font-semibold text-foreground flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-primary inline-block" />
{bloc}
<span className="text-xs text-muted-foreground font-normal ml-1">
({logiciels?.length} solution{(logiciels?.length ?? 0) > 1 ? "s" : ""})
</span>
</h3>
</div>
{/* Tableau logiciels */}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="text-left px-5 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider">Solution</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider hidden md:table-cell">Éditeur</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider">État</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider hidden lg:table-cell">Hébergement</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider hidden xl:table-cell">Facturation</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider hidden xl:table-cell">Interop.</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{logiciels?.map((l) => (
<tr key={l.id} className="hover:bg-muted/20 transition-colors">
<td className="px-5 py-3.5">
<div className="font-medium text-foreground">{l.solutionNom}</div>
{l.versionMajeure && (
<div className="text-xs text-muted-foreground">v{l.versionMajeure}</div>
)}
{l.commentaire && (
<div className="text-xs text-muted-foreground mt-0.5 italic">{l.commentaire}</div>
)}
</td>
<td className="px-4 py-3.5 hidden md:table-cell text-muted-foreground">{l.editeurNom}</td>
<td className="px-4 py-3.5">
<EtatBadge etat={l.etatDeploiement} />
</td>
<td className="px-4 py-3.5 hidden lg:table-cell">
{l.modeHebergement ? <HebergementBadge mode={l.modeHebergement} /> : <span className="text-muted-foreground text-xs"></span>}
</td>
<td className="px-4 py-3.5 hidden xl:table-cell">
{l.modeFacturation ? <FacturationBadge mode={l.modeFacturation} /> : <span className="text-muted-foreground text-xs"></span>}
</td>
<td className="px-4 py-3.5 hidden xl:table-cell">
{l.interoperabilite ? (
<span className="text-xs text-muted-foreground">{INTEROPERABILITE[l.interoperabilite]}</span>
) : <span className="text-muted-foreground text-xs"></span>}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
))}
</div>
)}
</div>
</div>
{showContact && (
<ContactModal
etablissementId={id}
etablissementNom={etab.nom}
onClose={() => setShowContact(false)}
/>
)}
</SonumLayout>
);
}

View File

@@ -1,31 +1,415 @@
import { useAuth } from "@/_core/hooks/useAuth";
import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
import { getLoginUrl } from "@/const";
import { Streamdown } from 'streamdown';
import CguModal from "@/components/CguModal";
import { EtatBadge } from "@/components/EtatBadge";
import SonumLayout from "@/components/SonumLayout";
import { trpc } from "@/lib/trpc";
import {
ArrowUpDown,
Building2,
ChevronDown,
ChevronUp,
Filter,
Mail,
MapPin,
RotateCcw,
Search,
SlidersHorizontal,
X,
} from "lucide-react";
import { useState, useMemo } from "react";
import { useLocation } from "wouter";
import { REGIONS, TYPES_ACTIVITE, TAILLES_EFFECTIFS } from "../../../shared/referentiel";
import ContactModal from "@/components/ContactModal";
/**
* All content in this page are only for example, replace with your own feature implementation
* When building pages, remember your instructions in Frontend Workflow, Frontend Best Practices, Design Guide and Common Pitfalls
*/
export default function Home() {
// The userAuth hooks provides authentication state
// To implement login/logout functionality, simply call logout() or redirect to getLoginUrl()
let { user, loading, error, isAuthenticated, logout } = useAuth();
const { user, isAuthenticated } = useAuth();
const [, navigate] = useLocation();
// If theme is switchable in App.tsx, we can implement theme toggling like this:
// const { theme, toggleTheme } = useTheme();
const [filters, setFilters] = useState({
blocFonctionnelId: undefined as number | undefined,
solutionId: undefined as number | undefined,
editeurId: undefined as number | undefined,
region: undefined as string | undefined,
typeActivite: undefined as string | undefined,
tailleEffectifs: undefined as string | undefined,
});
const [showAdvanced, setShowAdvanced] = useState(false);
const [searchText, setSearchText] = useState("");
const [contactEtab, setContactEtab] = useState<{ id: number; nom: string } | null>(null);
const [expandedEtab, setExpandedEtab] = useState<number | null>(null);
const [sortCol, setSortCol] = useState<"nom" | "region" | "typeActivite" | "tailleEffectifs">("nom");
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
const handleSort = (col: typeof sortCol) => {
if (sortCol === col) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
else { setSortCol(col); setSortDir("asc"); }
};
const cguQuery = trpc.cgu.status.useQuery(undefined, { enabled: isAuthenticated });
const blocsQuery = trpc.referentiel.blocsFonctionnels.useQuery();
const editeursQuery = trpc.referentiel.editeurs.useQuery();
const solutionsQuery = trpc.referentiel.solutions.useQuery({ search: searchText.length >= 2 ? searchText : undefined });
const searchQuery = trpc.etablissements.search.useQuery(filters, { enabled: isAuthenticated && (cguQuery.data?.accepted ?? false) });
const tracabiliteUtils = trpc.useUtils();
const recordConsultation = trpc.tracabilite.enregistrerConsultation.useMutation();
const sortedResults = useMemo(() => {
if (!searchQuery.data) return [];
return [...searchQuery.data].sort((a, b) => {
const aVal = (a[sortCol] ?? "").toString().toLowerCase();
const bVal = (b[sortCol] ?? "").toString().toLowerCase();
return sortDir === "asc" ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
});
}, [searchQuery.data, sortCol, sortDir]);
const handleViewEtab = (id: number) => {
setExpandedEtab(expandedEtab === id ? null : id);
if (expandedEtab !== id) {
recordConsultation.mutate({ etablissementId: id });
}
};
const resetFilters = () => {
setFilters({ blocFonctionnelId: undefined, solutionId: undefined, editeurId: undefined, region: undefined, typeActivite: undefined, tailleEffectifs: undefined });
setSearchText("");
};
const activeFilterCount = Object.values(filters).filter(Boolean).length;
if (!isAuthenticated) {
return (
<SonumLayout>
<div />
</SonumLayout>
);
}
if (cguQuery.isLoading) {
return (
<SonumLayout>
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary border-t-transparent" />
</div>
</SonumLayout>
);
}
if (!cguQuery.data?.accepted) {
return (
<SonumLayout>
<CguModal onAccepted={() => cguQuery.refetch()} />
</SonumLayout>
);
}
return (
<div className="min-h-screen flex flex-col">
<main>
{/* Example: lucide-react for icons */}
<Loader2 className="animate-spin" />
Example Page
{/* Example: Streamdown for markdown rendering */}
<Streamdown>Any **markdown** content</Streamdown>
<Button variant="default">Example Button</Button>
</main>
<SonumLayout>
<div className="p-6 lg:p-8 max-w-7xl mx-auto">
{/* En-tête */}
<div className="mb-8">
<h1 className="text-2xl font-bold text-foreground mb-1">Moteur de recherche SONUM</h1>
<p className="text-muted-foreground text-sm">
Recherchez les établissements adhérents et leurs solutions numériques
</p>
</div>
{/* Panneau de filtres */}
<div className="bg-card rounded-xl border border-border shadow-sm mb-6">
{/* Barre de recherche principale */}
<div className="p-5 border-b border-border">
<div className="flex gap-3">
<div className="relative flex-1">
<Search size={16} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input
type="text"
placeholder="Rechercher un logiciel, un éditeur..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-all"
/>
{searchText && (
<button onClick={() => setSearchText("")} className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
<X size={14} />
</button>
)}
</div>
<button
onClick={() => setShowAdvanced(!showAdvanced)}
className={`flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium border transition-all ${
showAdvanced || activeFilterCount > 0
? "bg-primary text-white border-primary"
: "bg-background border-border text-foreground hover:bg-muted"
}`}
>
<SlidersHorizontal size={15} />
Filtres
{activeFilterCount > 0 && (
<span className="bg-white/20 text-white text-xs px-1.5 py-0.5 rounded-full font-semibold">
{activeFilterCount}
</span>
)}
<ChevronDown size={14} className={`transition-transform ${showAdvanced ? "rotate-180" : ""}`} />
</button>
{activeFilterCount > 0 && (
<button
onClick={resetFilters}
className="flex items-center gap-1.5 px-3 py-2.5 rounded-lg text-sm text-muted-foreground hover:text-foreground hover:bg-muted border border-border transition-all"
>
<RotateCcw size={14} />
Réinitialiser
</button>
)}
</div>
</div>
{/* Filtres avancés */}
{showAdvanced && (
<div className="p-5 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{/* Bloc fonctionnel */}
<div>
<label className="block text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">
Bloc fonctionnel
</label>
<select
value={filters.blocFonctionnelId ?? ""}
onChange={(e) => setFilters((f) => ({ ...f, blocFonctionnelId: e.target.value ? Number(e.target.value) : undefined }))}
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary"
>
<option value="">Tous les blocs</option>
{blocsQuery.data?.map((b) => (
<option key={b.id} value={b.id}>{b.nom}</option>
))}
</select>
</div>
{/* Éditeur */}
<div>
<label className="block text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">
Éditeur
</label>
<select
value={filters.editeurId ?? ""}
onChange={(e) => setFilters((f) => ({ ...f, editeurId: e.target.value ? Number(e.target.value) : undefined }))}
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary"
>
<option value="">Tous les éditeurs</option>
{editeursQuery.data?.map((e) => (
<option key={e.id} value={e.id}>{e.nom}</option>
))}
</select>
</div>
{/* Région */}
<div>
<label className="block text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">
Région
</label>
<select
value={filters.region ?? ""}
onChange={(e) => setFilters((f) => ({ ...f, region: e.target.value || undefined }))}
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary"
>
<option value="">Toutes les régions</option>
{REGIONS.map((r) => (
<option key={r} value={r}>{r}</option>
))}
</select>
</div>
{/* Type d'activité */}
<div>
<label className="block text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">
Type d'activité
</label>
<select
value={filters.typeActivite ?? ""}
onChange={(e) => setFilters((f) => ({ ...f, typeActivite: e.target.value || undefined }))}
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary"
>
<option value="">Tous les types</option>
{TYPES_ACTIVITE.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
</div>
{/* Taille */}
<div>
<label className="block text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">
Taille d'établissement
</label>
<select
value={filters.tailleEffectifs ?? ""}
onChange={(e) => setFilters((f) => ({ ...f, tailleEffectifs: e.target.value || undefined }))}
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary"
>
<option value="">Toutes les tailles</option>
{TAILLES_EFFECTIFS.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
</div>
</div>
)}
</div>
{/* Résultats */}
<div>
{searchQuery.isLoading ? (
<div className="flex items-center justify-center py-16">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary border-t-transparent" />
</div>
) : searchQuery.data && searchQuery.data.length === 0 ? (
<div className="text-center py-16">
<Building2 size={48} className="mx-auto text-muted-foreground/30 mb-4" />
<p className="text-muted-foreground font-medium">Aucun établissement trouvé</p>
<p className="text-muted-foreground text-sm mt-1">Essayez de modifier vos critères de recherche</p>
</div>
) : (
<>
{searchQuery.data && (
<div className="flex items-center justify-between mb-4">
<p className="text-sm text-muted-foreground">
<span className="font-semibold text-foreground">{searchQuery.data.length}</span> établissement{searchQuery.data.length > 1 ? "s" : ""} trouvé{searchQuery.data.length > 1 ? "s" : ""}
</p>
<p className="text-xs text-muted-foreground">Cliquez sur un en-tête pour trier</p>
</div>
)}
<div className="bg-card rounded-xl border border-border shadow-sm overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/40">
{(["nom", "region", "typeActivite", "tailleEffectifs"] as const).map((col, i) => (
<th
key={col}
onClick={() => handleSort(col)}
className={`text-left px-${i === 0 ? 5 : 4} py-3.5 font-semibold text-muted-foreground text-xs uppercase tracking-wider cursor-pointer hover:text-foreground select-none transition-colors ${
i > 0 && i < 2 ? "hidden md:table-cell" : i >= 2 ? "hidden lg:table-cell" : ""
}`}
>
<span className="inline-flex items-center gap-1">
{["Établissement", "Région", "Type d'activité", "Taille"][i]}
{sortCol === col ? (
sortDir === "asc" ? <ChevronUp size={12} /> : <ChevronDown size={12} />
) : <ArrowUpDown size={11} className="opacity-40" />}
</span>
</th>
))}
<th className="text-right px-5 py-3.5 font-semibold text-muted-foreground text-xs uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{sortedResults.map((etab) => (
<>
<tr
key={etab.id}
className="hover:bg-muted/30 transition-colors cursor-pointer"
onClick={() => handleViewEtab(etab.id)}
>
<td className="px-5 py-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
<Building2 size={15} className="text-primary" />
</div>
<div>
<div className="font-medium text-foreground">{etab.nom}</div>
{etab.finess && <div className="text-xs text-muted-foreground">FINESS : {etab.finess}</div>}
</div>
</div>
</td>
<td className="px-4 py-4 hidden md:table-cell">
<div className="flex items-center gap-1.5 text-muted-foreground">
<MapPin size={13} />
<span>{etab.region ?? "—"}</span>
</div>
</td>
<td className="px-4 py-4 hidden lg:table-cell">
<span className="text-muted-foreground">{etab.typeActivite ?? "—"}</span>
</td>
<td className="px-4 py-4 hidden lg:table-cell">
<span className="text-muted-foreground text-xs">{etab.tailleEffectifs ?? "—"}</span>
</td>
<td className="px-5 py-4 text-right">
<div className="flex items-center justify-end gap-2">
{etab.accepteMiseEnRelation && (
<button
onClick={(e) => { e.stopPropagation(); setContactEtab({ id: etab.id, nom: etab.nom }); }}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-primary/10 text-primary hover:bg-primary hover:text-white rounded-lg transition-all border border-primary/20"
>
<Mail size={12} />
Contacter
</button>
)}
<button
onClick={(e) => { e.stopPropagation(); navigate(`/etablissement/${etab.id}`); }}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-muted text-foreground hover:bg-secondary rounded-lg transition-all border border-border"
>
Voir la fiche
</button>
</div>
</td>
</tr>
{expandedEtab === etab.id && (
<tr key={`${etab.id}-expanded`} className="bg-muted/20">
<td colSpan={5} className="px-5 py-4">
<LogicielsInline etablissementId={etab.id} />
</td>
</tr>
)}
</>
))}
</tbody>
</table>
</div>
</>
)}
</div>
</div>
{/* Modale de contact */}
{contactEtab && (
<ContactModal
etablissementId={contactEtab.id}
etablissementNom={contactEtab.nom}
onClose={() => setContactEtab(null)}
/>
)}
</SonumLayout>
);
}
function LogicielsInline({ etablissementId }: { etablissementId: number }) {
const logicielsQuery = trpc.logiciels.byEtablissement.useQuery({ etablissementId });
if (logicielsQuery.isLoading) {
return <div className="py-3 text-sm text-muted-foreground">Chargement...</div>;
}
if (!logicielsQuery.data?.length) {
return <div className="py-3 text-sm text-muted-foreground italic">Aucun logiciel renseigné pour cet établissement.</div>;
}
return (
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Logiciels de l'établissement</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{logicielsQuery.data.map((l) => (
<div key={l.id} className="bg-card rounded-lg border border-border p-3">
<div className="font-medium text-sm text-foreground mb-1">{l.solutionNom}</div>
<div className="text-xs text-muted-foreground mb-2">{l.editeurNom}</div>
<div className="flex flex-wrap gap-1">
<EtatBadge etat={l.etatDeploiement} />
{l.blocFonctionnelNom && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs bg-secondary text-secondary-foreground border border-border">
{l.blocFonctionnelNom}
</span>
)}
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,248 @@
import { useAuth } from "@/_core/hooks/useAuth";
import SonumLayout from "@/components/SonumLayout";
import { trpc } from "@/lib/trpc";
import {
Building2,
CheckCircle2,
ChevronDown,
Clock,
Mail,
MessageSquare,
Send,
User,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { STATUTS_DEMANDE } from "../../../shared/referentiel";
export default function MesDemandes() {
const { user } = useAuth();
const isGestionnaire = user?.sonumRole === "gestionnaire" || user?.role === "admin";
const mesDemandes = trpc.contact.mesDemandes.useQuery();
const demandesRecues = trpc.contact.demandesRecues.useQuery();
const toutesLesDemandes = trpc.contact.toutesLesDemandes.useQuery(undefined, {
enabled: isGestionnaire,
});
const [activeTab, setActiveTab] = useState<"envoyees" | "recues" | "toutes">("recues");
const [replyingTo, setReplyingTo] = useState<number | null>(null);
const tabs = [
{ id: "recues" as const, label: "Reçues", count: demandesRecues.data?.length ?? 0 },
{ id: "envoyees" as const, label: "Envoyées", count: mesDemandes.data?.length ?? 0 },
...(isGestionnaire ? [{ id: "toutes" as const, label: "Toutes (admin)", count: toutesLesDemandes.data?.length ?? 0 }] : []),
];
const currentData =
activeTab === "envoyees"
? mesDemandes.data
: activeTab === "toutes"
? toutesLesDemandes.data
: demandesRecues.data;
const isLoading =
activeTab === "envoyees"
? mesDemandes.isLoading
: activeTab === "toutes"
? toutesLesDemandes.isLoading
: demandesRecues.isLoading;
return (
<SonumLayout>
<div className="p-6 lg:p-8 max-w-4xl mx-auto">
{/* En-tête */}
<div className="mb-8">
<h1 className="text-2xl font-bold text-foreground mb-1">Mes Demandes de Contact</h1>
<p className="text-muted-foreground text-sm">
Suivez vos échanges avec les référents numériques des établissements
</p>
</div>
{/* Onglets */}
<div className="flex gap-1 p-1 bg-muted rounded-xl mb-6 w-fit">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
activeTab === tab.id
? "bg-card text-foreground shadow-sm border border-border"
: "text-muted-foreground hover:text-foreground"
}`}
>
{tab.label}
{tab.count > 0 && (
<span className={`text-xs px-1.5 py-0.5 rounded-full font-semibold ${
activeTab === tab.id ? "bg-primary text-white" : "bg-border text-muted-foreground"
}`}>
{tab.count}
</span>
)}
</button>
))}
</div>
{/* Liste des demandes */}
{isLoading ? (
<div className="flex items-center justify-center py-16">
<div className="animate-spin rounded-full h-7 w-7 border-2 border-primary border-t-transparent" />
</div>
) : !currentData?.length ? (
<div className="text-center py-16 bg-card rounded-xl border border-border">
<Mail size={48} className="mx-auto text-muted-foreground/30 mb-4" />
<p className="text-muted-foreground font-medium">Aucune demande</p>
<p className="text-muted-foreground text-sm mt-1">
{activeTab === "envoyees"
? "Vous n'avez pas encore envoyé de demande de contact."
: "Vous n'avez pas encore reçu de demande de contact."}
</p>
</div>
) : (
<div className="space-y-3">
{currentData.map((demande) => (
<DemandeCard
key={demande.id}
demande={demande}
isEnvoyee={activeTab === "envoyees"}
isReplying={replyingTo === demande.id}
onReply={() => setReplyingTo(replyingTo === demande.id ? null : demande.id)}
onReplied={() => {
setReplyingTo(null);
demandesRecues.refetch();
toutesLesDemandes.refetch();
}}
/>
))}
</div>
)}
</div>
</SonumLayout>
);
}
function DemandeCard({
demande,
isEnvoyee,
isReplying,
onReply,
onReplied,
}: {
demande: any;
isEnvoyee: boolean;
isReplying: boolean;
onReply: () => void;
onReplied: () => void;
}) {
const [reponse, setReponse] = useState("");
const repondreMutation = trpc.contact.repondre.useMutation({
onSuccess: () => {
toast.success("Réponse envoyée avec succès.");
setReponse("");
onReplied();
},
onError: (err) => toast.error("Erreur : " + err.message),
});
const statusConfig: Record<string, { icon: React.ReactNode; color: string; label: string }> = {
en_attente: { icon: <Clock size={13} />, color: "bg-amber-100 text-amber-700 border-amber-200", label: "En attente" },
repondu: { icon: <CheckCircle2 size={13} />, color: "bg-green-100 text-green-700 border-green-200", label: "Répondu" },
ferme: { icon: <CheckCircle2 size={13} />, color: "bg-gray-100 text-gray-600 border-gray-200", label: "Fermé" },
};
const status = statusConfig[demande.statut] ?? statusConfig.en_attente;
return (
<div className="bg-card rounded-xl border border-border shadow-sm overflow-hidden">
{/* En-tête */}
<div className="flex items-start justify-between px-5 py-4 border-b border-border">
<div className="flex items-start gap-3">
<div className="w-9 h-9 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<User size={16} className="text-primary" />
</div>
<div>
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-sm text-foreground">{demande.demandeurNom}</span>
<span className="text-muted-foreground text-xs">{demande.demandeurEmail}</span>
</div>
<div className="flex items-center gap-2 mt-0.5">
<Building2 size={12} className="text-muted-foreground" />
<span className="text-xs text-muted-foreground">{demande.etablissementNom}</span>
</div>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<span className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium border ${status.color}`}>
{status.icon}
{status.label}
</span>
<span className="text-xs text-muted-foreground">
{new Date(demande.createdAt).toLocaleDateString("fr-FR", { day: "2-digit", month: "short", year: "numeric" })}
</span>
</div>
</div>
{/* Message */}
<div className="px-5 py-4">
<div className="flex items-start gap-2 mb-3">
<MessageSquare size={14} className="text-muted-foreground mt-0.5 flex-shrink-0" />
<p className="text-sm text-foreground leading-relaxed">{demande.message}</p>
</div>
{/* Réponse existante */}
{demande.reponse && (
<div className="mt-3 ml-5 pl-4 border-l-2 border-primary/30">
<p className="text-xs font-semibold text-primary mb-1">Réponse</p>
<p className="text-sm text-foreground leading-relaxed">{demande.reponse}</p>
{demande.reponduAt && (
<p className="text-xs text-muted-foreground mt-1">
{new Date(demande.reponduAt).toLocaleDateString("fr-FR", { day: "2-digit", month: "short", year: "numeric" })}
</p>
)}
</div>
)}
</div>
{/* Actions */}
{!isEnvoyee && demande.statut === "en_attente" && (
<div className="px-5 pb-4">
{!isReplying ? (
<button
onClick={onReply}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-primary/10 text-primary hover:bg-primary hover:text-white rounded-lg transition-all border border-primary/20"
>
<Send size={12} />
Répondre
</button>
) : (
<div className="space-y-3">
<textarea
value={reponse}
onChange={(e) => setReponse(e.target.value)}
placeholder="Votre réponse..."
rows={3}
className="w-full px-3 py-2.5 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 resize-none"
/>
<div className="flex gap-2">
<button
onClick={() => onReply()}
className="px-3 py-1.5 text-xs font-medium border border-border rounded-lg text-foreground hover:bg-muted transition-colors"
>
Annuler
</button>
<button
onClick={() => repondreMutation.mutate({ id: demande.id, reponse })}
disabled={!reponse.trim() || repondreMutation.isPending}
className="flex items-center gap-1.5 px-4 py-1.5 text-xs font-medium bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 shadow-sm"
>
<Send size={12} />
{repondreMutation.isPending ? "Envoi..." : "Envoyer la réponse"}
</button>
</div>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,369 @@
import { useAuth } from "@/_core/hooks/useAuth";
import { EtatBadge, HebergementBadge, FacturationBadge } from "@/components/EtatBadge";
import SonumLayout from "@/components/SonumLayout";
import { trpc } from "@/lib/trpc";
import {
Building2,
ChevronDown,
ChevronRight,
Edit3,
Eye,
EyeOff,
Pencil,
Plus,
Save,
Trash2,
X,
} from "lucide-react";
import { useState } from "react";
import { useLocation } from "wouter";
import { toast } from "sonner";
import RattacherSolutionModal from "@/components/RattacherSolutionModal";
export default function MesEtablissements() {
const { user } = useAuth();
const [, navigate] = useLocation();
const [openEtab, setOpenEtab] = useState<number | null>(null);
const [editMode, setEditMode] = useState<number | null>(null);
const [rattacherEtab, setRattacherEtab] = useState<{ id: number; nom: string } | null>(null);
const etablissementsQuery = trpc.etablissements.mesEtablissements.useQuery();
const utils = trpc.useUtils();
const toggleAccordion = (id: number) => {
setOpenEtab(openEtab === id ? null : id);
setEditMode(null);
};
if (etablissementsQuery.isLoading) {
return (
<SonumLayout>
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary border-t-transparent" />
</div>
</SonumLayout>
);
}
const etablissements = etablissementsQuery.data ?? [];
return (
<SonumLayout>
<div className="p-6 lg:p-8 max-w-5xl mx-auto">
{/* En-tête */}
<div className="mb-8 flex items-start justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground mb-1">Mes Établissements</h1>
<p className="text-muted-foreground text-sm">
Gérez les logiciels de vos établissements rattachés
</p>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-muted px-3 py-1.5 rounded-lg border border-border">
<Building2 size={15} />
<span>{etablissements.length} établissement{etablissements.length > 1 ? "s" : ""}</span>
</div>
</div>
{etablissements.length === 0 ? (
<div className="text-center py-16 bg-card rounded-xl border border-border">
<Building2 size={48} className="mx-auto text-muted-foreground/30 mb-4" />
<p className="text-muted-foreground font-medium">Aucun établissement rattaché</p>
<p className="text-muted-foreground text-sm mt-1">
Contactez un gestionnaire SONUM pour rattacher vos établissements.
</p>
</div>
) : (
<div className="space-y-3">
{etablissements.map((etab) => (
<EtablissementAccordion
key={etab.id}
etab={etab}
isOpen={openEtab === etab.id}
isEditMode={editMode === etab.id}
onToggle={() => toggleAccordion(etab.id)}
onEditMode={() => setEditMode(editMode === etab.id ? null : etab.id)}
onRattacher={() => setRattacherEtab({ id: etab.id, nom: etab.nom })}
onViewFiche={() => navigate(`/etablissement/${etab.id}`)}
onRefresh={() => utils.etablissements.mesEtablissements.invalidate()}
/>
))}
</div>
)}
</div>
{rattacherEtab && (
<RattacherSolutionModal
etablissementId={rattacherEtab.id}
etablissementNom={rattacherEtab.nom}
onClose={() => setRattacherEtab(null)}
onSuccess={() => {
setRattacherEtab(null);
utils.logiciels.byEtablissement.invalidate({ etablissementId: rattacherEtab.id });
}}
/>
)}
</SonumLayout>
);
}
function EtablissementAccordion({
etab,
isOpen,
isEditMode,
onToggle,
onEditMode,
onRattacher,
onViewFiche,
onRefresh,
}: {
etab: any;
isOpen: boolean;
isEditMode: boolean;
onToggle: () => void;
onEditMode: () => void;
onRattacher: () => void;
onViewFiche: () => void;
onRefresh: () => void;
}) {
const logicielsQuery = trpc.logiciels.byEtablissement.useQuery(
{ etablissementId: etab.id },
{ enabled: isOpen }
);
const deleteMutation = trpc.logiciels.delete.useMutation({
onSuccess: () => {
toast.success("Logiciel supprimé");
logicielsQuery.refetch();
},
});
const utils = trpc.useUtils();
return (
<div className="bg-card rounded-xl border border-border shadow-sm overflow-hidden transition-all duration-200">
{/* En-tête accordéon */}
<button
className="w-full flex items-center justify-between px-5 py-4 hover:bg-muted/30 transition-colors text-left"
onClick={onToggle}
>
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
<Building2 size={17} className="text-primary" />
</div>
<div>
<div className="font-semibold text-foreground">{etab.nom}</div>
<div className="flex items-center gap-3 mt-0.5">
{etab.finess && <span className="text-xs text-muted-foreground">FINESS : {etab.finess}</span>}
{etab.region && <span className="text-xs text-muted-foreground">{etab.region}</span>}
{etab.typeActivite && (
<span className="text-xs bg-secondary text-secondary-foreground px-2 py-0.5 rounded border border-border">
{etab.typeActivite}
</span>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground hidden sm:block">
{isOpen ? "Masquer" : "Voir les logiciels"}
</span>
{isOpen ? (
<ChevronDown size={18} className="text-muted-foreground transition-transform" />
) : (
<ChevronRight size={18} className="text-muted-foreground" />
)}
</div>
</button>
{/* Contenu accordéon */}
{isOpen && (
<div className="border-t border-border">
{/* Barre d'actions */}
<div className="flex items-center justify-between px-5 py-3 bg-muted/20 border-b border-border">
<div className="flex items-center gap-2">
<button
onClick={onRattacher}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors shadow-sm"
>
<Plus size={13} />
Rattacher une solution
</button>
<button
onClick={onEditMode}
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors border ${
isEditMode
? "bg-amber-50 text-amber-700 border-amber-200"
: "bg-background text-foreground border-border hover:bg-muted"
}`}
>
<Edit3 size={13} />
{isEditMode ? "Mode édition actif" : "Édition complète"}
</button>
</div>
<button
onClick={onViewFiche}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted rounded-lg transition-colors border border-border"
>
<Eye size={13} />
Voir la fiche
</button>
</div>
{/* Tableau des logiciels */}
{logicielsQuery.isLoading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-2 border-primary border-t-transparent" />
</div>
) : !logicielsQuery.data?.length ? (
<div className="text-center py-8 text-muted-foreground text-sm">
<p>Aucun logiciel renseigné pour cet établissement.</p>
<button onClick={onRattacher} className="mt-2 text-primary hover:underline text-sm font-medium">
Rattacher une première solution
</button>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/20">
<th className="text-left px-5 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider">Solution</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider hidden md:table-cell">Éditeur</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider hidden lg:table-cell">Bloc fonctionnel</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider">État</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider hidden xl:table-cell">Hébergement</th>
{isEditMode && (
<th className="text-right px-5 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider">Actions</th>
)}
</tr>
</thead>
<tbody className="divide-y divide-border">
{logicielsQuery.data.map((logiciel) => (
<LogicielRow
key={logiciel.id}
logiciel={logiciel}
isEditMode={isEditMode}
etablissementId={etab.id}
onDelete={() => deleteMutation.mutate({ id: logiciel.id, etablissementId: etab.id })}
onUpdated={() => logicielsQuery.refetch()}
/>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
);
}
function LogicielRow({
logiciel,
isEditMode,
etablissementId,
onDelete,
onUpdated,
}: {
logiciel: any;
isEditMode: boolean;
etablissementId: number;
onDelete: () => void;
onUpdated: () => void;
}) {
const [editing, setEditing] = useState(false);
const [etat, setEtat] = useState(logiciel.etatDeploiement);
const [commentaire, setCommentaire] = useState(logiciel.commentaire ?? "");
const updateMutation = trpc.logiciels.upsert.useMutation({
onSuccess: () => {
toast.success("Logiciel mis à jour");
setEditing(false);
onUpdated();
},
});
if (editing && isEditMode) {
return (
<tr className="bg-amber-50/50">
<td className="px-5 py-3 font-medium">{logiciel.solutionNom}</td>
<td className="px-4 py-3 hidden md:table-cell text-muted-foreground">{logiciel.editeurNom}</td>
<td className="px-4 py-3 hidden lg:table-cell text-muted-foreground text-xs">{logiciel.blocFonctionnelNom}</td>
<td className="px-4 py-3">
<select
value={etat}
onChange={(e) => setEtat(e.target.value)}
className="text-xs border border-border rounded px-2 py-1 bg-background"
>
<option value="demarrage">Démarrage</option>
<option value="en_cours">En cours</option>
<option value="operationnel">Opérationnel</option>
<option value="en_remplacement">En remplacement</option>
</select>
</td>
<td className="px-4 py-3 hidden xl:table-cell">
<input
value={commentaire}
onChange={(e) => setCommentaire(e.target.value)}
placeholder="Commentaire..."
className="text-xs border border-border rounded px-2 py-1 bg-background w-full"
/>
</td>
<td className="px-5 py-3 text-right">
<div className="flex items-center justify-end gap-1.5">
<button
onClick={() => updateMutation.mutate({
id: logiciel.id,
etablissementId,
solutionId: logiciel.solutionId,
etatDeploiement: etat,
commentaire,
})}
className="p-1.5 rounded bg-green-100 text-green-700 hover:bg-green-200 transition-colors"
>
<Save size={13} />
</button>
<button onClick={() => setEditing(false)} className="p-1.5 rounded bg-muted text-muted-foreground hover:bg-secondary transition-colors">
<X size={13} />
</button>
</div>
</td>
</tr>
);
}
return (
<tr className="hover:bg-muted/20 transition-colors">
<td className="px-5 py-3.5 font-medium text-foreground">{logiciel.solutionNom}</td>
<td className="px-4 py-3.5 hidden md:table-cell text-muted-foreground">{logiciel.editeurNom}</td>
<td className="px-4 py-3.5 hidden lg:table-cell">
{logiciel.blocFonctionnelNom && (
<span className="text-xs bg-secondary text-secondary-foreground px-2 py-0.5 rounded border border-border">
{logiciel.blocFonctionnelNom}
</span>
)}
</td>
<td className="px-4 py-3.5">
<EtatBadge etat={logiciel.etatDeploiement} />
</td>
<td className="px-4 py-3.5 hidden xl:table-cell">
{logiciel.modeHebergement ? <HebergementBadge mode={logiciel.modeHebergement} /> : <span className="text-muted-foreground text-xs"></span>}
</td>
{isEditMode && (
<td className="px-5 py-3.5 text-right">
<div className="flex items-center justify-end gap-1.5">
<button
onClick={() => setEditing(true)}
className="p-1.5 rounded bg-blue-50 text-blue-600 hover:bg-blue-100 transition-colors"
>
<Pencil size={13} />
</button>
<button
onClick={onDelete}
className="p-1.5 rounded bg-red-50 text-red-600 hover:bg-red-100 transition-colors"
>
<Trash2 size={13} />
</button>
</div>
</td>
)}
</tr>
);
}

View File

@@ -1,52 +1,24 @@
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { AlertCircle, Home } from "lucide-react";
import { ArrowLeft, Search } from "lucide-react";
import { useLocation } from "wouter";
export default function NotFound() {
const [, setLocation] = useLocation();
const handleGoHome = () => {
setLocation("/");
};
const [, navigate] = useLocation();
return (
<div className="min-h-screen w-full flex items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100">
<Card className="w-full max-w-lg mx-4 shadow-lg border-0 bg-white/80 backdrop-blur-sm">
<CardContent className="pt-8 pb-8 text-center">
<div className="flex justify-center mb-6">
<div className="relative">
<div className="absolute inset-0 bg-red-100 rounded-full animate-pulse" />
<AlertCircle className="relative h-16 w-16 text-red-500" />
</div>
</div>
<h1 className="text-4xl font-bold text-slate-900 mb-2">404</h1>
<h2 className="text-xl font-semibold text-slate-700 mb-4">
Page Not Found
</h2>
<p className="text-slate-600 mb-8 leading-relaxed">
Sorry, the page you are looking for doesn't exist.
<br />
It may have been moved or deleted.
</p>
<div
id="not-found-button-group"
className="flex flex-col sm:flex-row gap-3 justify-center"
>
<Button
onClick={handleGoHome}
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2.5 rounded-lg transition-all duration-200 shadow-md hover:shadow-lg"
>
<Home className="w-4 h-4 mr-2" />
Go Home
</Button>
</div>
</CardContent>
</Card>
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center max-w-sm mx-auto px-6">
<div className="w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center mx-auto mb-6">
<Search size={32} className="text-primary" />
</div>
<h1 className="text-4xl font-bold text-foreground mb-2">404</h1>
<p className="text-muted-foreground mb-6">Cette page n'existe pas ou vous n'y avez pas accès.</p>
<button
onClick={() => navigate("/")}
className="inline-flex items-center gap-2 px-5 py-2.5 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary/90 transition-colors shadow-sm"
>
<ArrowLeft size={15} />
Retour à l'accueil
</button>
</div>
</div>
);
}