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

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>
);
}