Checkpoint: v4 : Ajout pages Mes Solutions Numériques et Solutions Logicielles, onglets Import Établissements et Import Contacts avec CSV dans Admin, correction moteur de recherche (filtres solutionId/editeurId/blocFonctionnelId), CGU réaffichée à chaque session. 33 tests passés, 0 erreur TypeScript.

This commit is contained in:
Manus
2026-04-16 12:20:42 -04:00
parent b86977eae4
commit a86806fbb7
9 changed files with 1051 additions and 22 deletions

View File

@@ -0,0 +1,179 @@
import { trpc } from "@/lib/trpc";
import { useAuth } from "@/_core/hooks/useAuth";
import SonumLayout from "@/components/SonumLayout";
import { ChevronDown, ChevronRight, Building2, Package, Search, Filter } from "lucide-react";
import { useState, useMemo } from "react";
import { EtatBadge } from "@/components/EtatBadge";
export default function MesSolutions() {
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.mesSolutions.useQuery(undefined, {
enabled: isAuthenticated,
});
const { data: blocs } = trpc.referentiel.blocsFonctionnels.useQuery();
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);
}, [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-2">
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center">
<Package size={20} className="text-primary" />
</div>
<div>
<h1 className="text-2xl font-bold text-foreground">Mes Solutions Numériques</h1>
<p className="text-sm text-muted-foreground">
{solutions ? `${solutions.length} solution${solutions.length > 1 ? "s" : ""} référencée${solutions.length > 1 ? "s" : ""}` : "Chargement…"}
</p>
</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>
{/* Liste */}
{isLoading ? (
<div className="space-y-3">
{[...Array(4)].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>
<p className="text-sm mt-1">Rattachez des solutions à vos établissements pour les voir apparaître ici.</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-primary/10 flex items-center justify-center flex-shrink-0">
<Package size={16} className="text-primary" />
</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">
<span className="text-xs text-muted-foreground bg-muted px-2.5 py-1 rounded-full">
{sol.etablissements.length} établissement{sol.etablissements.length > 1 ? "s" : ""}
</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">
<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>
);
}