SONUM v7 - Évolution v6 (éditeurs/blocs CRUD, tableau de bord stats) + vue liste alternance couleurs
This commit is contained in:
240
client/src/pages/FicheEtablissement.tsx
Normal file
240
client/src/pages/FicheEtablissement.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user