222 lines
10 KiB
TypeScript
222 lines
10 KiB
TypeScript
import { trpc } from "@/lib/trpc";
|
|
import { useAuth } from "@/_core/hooks/useAuth";
|
|
import SonumLayout from "@/components/SonumLayout";
|
|
import { ChevronDown, ChevronRight, Building2, Package, Search, Filter, BarChart2 } from "lucide-react";
|
|
import { useState, useMemo } from "react";
|
|
import { EtatBadge } from "@/components/EtatBadge";
|
|
|
|
export default function SolutionsLogicielles() {
|
|
const { isAuthenticated } = useAuth();
|
|
const [openIds, setOpenIds] = useState<Set<number>>(new Set());
|
|
const [search, setSearch] = useState("");
|
|
const [filterBloc, setFilterBloc] = useState("");
|
|
|
|
const { data: solutions, isLoading } = trpc.logiciels.toutesLesSolutions.useQuery(undefined, {
|
|
enabled: isAuthenticated,
|
|
});
|
|
|
|
const toggle = (id: number) => {
|
|
setOpenIds((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(id)) next.delete(id);
|
|
else next.add(id);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const filtered = useMemo(() => {
|
|
if (!solutions) return [];
|
|
return solutions.filter((s) => {
|
|
const matchSearch =
|
|
!search ||
|
|
s.solutionNom.toLowerCase().includes(search.toLowerCase()) ||
|
|
s.editeurNom.toLowerCase().includes(search.toLowerCase());
|
|
const matchBloc = !filterBloc || s.blocFonctionnelNom === filterBloc;
|
|
return matchSearch && matchBloc;
|
|
});
|
|
}, [solutions, search, filterBloc]);
|
|
|
|
const blocsUniques = useMemo(() => {
|
|
if (!solutions) return [];
|
|
const set = new Set(solutions.map((s) => s.blocFonctionnelNom).filter(Boolean) as string[]);
|
|
return Array.from(set).sort();
|
|
}, [solutions]);
|
|
|
|
const totalEtablissements = useMemo(() => {
|
|
if (!solutions) return 0;
|
|
const ids = new Set<number>();
|
|
solutions.forEach((s) => s.etablissements.forEach((e) => ids.add(e.id)));
|
|
return ids.size;
|
|
}, [solutions]);
|
|
|
|
return (
|
|
<SonumLayout>
|
|
<div className="max-w-5xl mx-auto px-4 py-8">
|
|
{/* En-tête */}
|
|
<div className="mb-8">
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<div className="w-10 h-10 rounded-xl bg-blue-500/10 flex items-center justify-center">
|
|
<BarChart2 size={20} className="text-blue-600" />
|
|
</div>
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-foreground">Solutions Logicielles</h1>
|
|
<p className="text-sm text-muted-foreground">Référentiel complet des solutions numériques FEHAP</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Statistiques */}
|
|
{solutions && (
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 mb-2">
|
|
<div className="bg-card border border-border rounded-xl p-4 text-center">
|
|
<div className="text-2xl font-bold text-primary">{solutions.length}</div>
|
|
<div className="text-xs text-muted-foreground mt-0.5">Solutions référencées</div>
|
|
</div>
|
|
<div className="bg-card border border-border rounded-xl p-4 text-center">
|
|
<div className="text-2xl font-bold text-blue-600">{totalEtablissements}</div>
|
|
<div className="text-xs text-muted-foreground mt-0.5">Établissements équipés</div>
|
|
</div>
|
|
<div className="bg-card border border-border rounded-xl p-4 text-center col-span-2 sm:col-span-1">
|
|
<div className="text-2xl font-bold text-green-600">{blocsUniques.length}</div>
|
|
<div className="text-xs text-muted-foreground mt-0.5">Blocs fonctionnels</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Filtres */}
|
|
<div className="flex flex-col sm:flex-row gap-3 mb-6">
|
|
<div className="relative flex-1">
|
|
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
|
<input
|
|
type="text"
|
|
placeholder="Rechercher par solution ou éditeur…"
|
|
value={search}
|
|
onChange={(e) => setSearch(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 className="relative">
|
|
<Filter size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
|
<select
|
|
value={filterBloc}
|
|
onChange={(e) => setFilterBloc(e.target.value)}
|
|
className="pl-9 pr-8 py-2.5 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 appearance-none min-w-[200px]"
|
|
>
|
|
<option value="">Tous les blocs fonctionnels</option>
|
|
{blocsUniques.map((b) => (
|
|
<option key={b} value={b}>{b}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Résultat filtré */}
|
|
{search || filterBloc ? (
|
|
<p className="text-xs text-muted-foreground mb-4">
|
|
{filtered.length} solution{filtered.length > 1 ? "s" : ""} correspondant aux critères
|
|
</p>
|
|
) : null}
|
|
|
|
{/* Liste */}
|
|
{isLoading ? (
|
|
<div className="space-y-3">
|
|
{[...Array(6)].map((_, i) => (
|
|
<div key={i} className="h-16 bg-muted/30 rounded-xl animate-pulse" />
|
|
))}
|
|
</div>
|
|
) : filtered.length === 0 ? (
|
|
<div className="text-center py-16 text-muted-foreground">
|
|
<Package size={40} className="mx-auto mb-3 opacity-30" />
|
|
<p className="font-medium">Aucune solution trouvée</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{filtered.map((sol) => {
|
|
const isOpen = openIds.has(sol.solutionId);
|
|
return (
|
|
<div
|
|
key={sol.solutionId}
|
|
className="border border-border rounded-xl overflow-hidden bg-card shadow-sm hover:shadow-md transition-shadow"
|
|
>
|
|
{/* En-tête accordéon */}
|
|
<button
|
|
onClick={() => toggle(sol.solutionId)}
|
|
className="w-full flex items-center justify-between px-5 py-4 text-left hover:bg-muted/30 transition-colors"
|
|
>
|
|
<div className="flex items-center gap-4 min-w-0">
|
|
<div className="w-9 h-9 rounded-lg bg-blue-500/10 flex items-center justify-center flex-shrink-0">
|
|
<Package size={16} className="text-blue-600" />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<div className="font-semibold text-foreground truncate">{sol.solutionNom}</div>
|
|
<div className="text-xs text-muted-foreground">{sol.editeurNom}</div>
|
|
</div>
|
|
{sol.blocFonctionnelNom && (
|
|
<span className="hidden sm:inline-flex text-xs bg-secondary text-secondary-foreground px-2.5 py-1 rounded-full border border-border flex-shrink-0">
|
|
{sol.blocFonctionnelNom}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-3 flex-shrink-0 ml-3">
|
|
{sol.nbEtablissements > 0 ? (
|
|
<span className="text-xs text-muted-foreground bg-muted px-2.5 py-1 rounded-full">
|
|
{sol.nbEtablissements} établissement{sol.nbEtablissements > 1 ? "s" : ""}
|
|
</span>
|
|
) : (
|
|
<span className="text-xs text-muted-foreground/50 bg-muted/50 px-2.5 py-1 rounded-full italic">
|
|
Non déployée
|
|
</span>
|
|
)}
|
|
{isOpen ? (
|
|
<ChevronDown size={16} className="text-muted-foreground" />
|
|
) : (
|
|
<ChevronRight size={16} className="text-muted-foreground" />
|
|
)}
|
|
</div>
|
|
</button>
|
|
|
|
{/* Contenu accordéon */}
|
|
{isOpen && (
|
|
<div className="border-t border-border bg-muted/10">
|
|
<div className="px-5 py-3">
|
|
{sol.etablissements.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground italic py-2">
|
|
Aucun établissement n'utilise encore cette solution.
|
|
</p>
|
|
) : (
|
|
<>
|
|
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">
|
|
Établissements équipés
|
|
</div>
|
|
<div className="space-y-2">
|
|
{sol.etablissements.map((etab) => (
|
|
<div
|
|
key={etab.id}
|
|
className="flex items-center justify-between py-2 px-3 bg-background rounded-lg border border-border"
|
|
>
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<Building2 size={14} className="text-muted-foreground flex-shrink-0" />
|
|
<span className="text-sm font-medium text-foreground truncate">{etab.nom}</span>
|
|
{etab.region && (
|
|
<span className="text-xs text-muted-foreground hidden sm:block">— {etab.region}</span>
|
|
)}
|
|
</div>
|
|
<EtatBadge etat={etab.etatDeploiement} />
|
|
</div>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</SonumLayout>
|
|
);
|
|
}
|