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

0
.gitkeep Normal file
View File

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

View File

@@ -0,0 +1,13 @@
CREATE TABLE `users` (
`id` int AUTO_INCREMENT NOT NULL,
`openId` varchar(64) NOT NULL,
`name` text,
`email` varchar(320),
`loginMethod` varchar(64),
`role` enum('user','admin') NOT NULL DEFAULT 'user',
`createdAt` timestamp NOT NULL DEFAULT (now()),
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
`lastSignedIn` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `users_id` PRIMARY KEY(`id`),
CONSTRAINT `users_openId_unique` UNIQUE(`openId`)
);

View File

@@ -0,0 +1,89 @@
CREATE TABLE `blocs_fonctionnels` (
`id` int AUTO_INCREMENT NOT NULL,
`nom` varchar(255) NOT NULL,
`estValide` boolean NOT NULL DEFAULT true,
`createdAt` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `blocs_fonctionnels_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `consultations` (
`id` int AUTO_INCREMENT NOT NULL,
`etablissementId` int NOT NULL,
`consultePar` int NOT NULL,
`consulteParNom` varchar(255),
`createdAt` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `consultations_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `demandes_contact` (
`id` int AUTO_INCREMENT NOT NULL,
`etablissementCibleId` int NOT NULL,
`demandeurId` int NOT NULL,
`demandeurNom` varchar(255),
`demandeurEmail` varchar(320),
`message` text NOT NULL,
`statut` enum('en_attente','repondu','ferme') NOT NULL DEFAULT 'en_attente',
`reponse` text,
`reponsePar` int,
`reponduAt` timestamp,
`createdAt` timestamp NOT NULL DEFAULT (now()),
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `demandes_contact_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `editeurs` (
`id` int AUTO_INCREMENT NOT NULL,
`nom` varchar(255) NOT NULL,
`estValide` boolean NOT NULL DEFAULT true,
`createdAt` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `editeurs_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `etablissements` (
`id` int AUTO_INCREMENT NOT NULL,
`finess` varchar(20),
`nom` varchar(255) NOT NULL,
`region` varchar(100),
`departement` varchar(100),
`typeActivite` varchar(100),
`tailleEffectifs` varchar(50),
`referentId` int,
`visibilite` enum('tous','gestionnaires') NOT NULL DEFAULT 'tous',
`accepteMiseEnRelation` boolean NOT NULL DEFAULT true,
`createdAt` timestamp NOT NULL DEFAULT (now()),
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `etablissements_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `logiciels_etablissements` (
`id` int AUTO_INCREMENT NOT NULL,
`etablissementId` int NOT NULL,
`solutionId` int NOT NULL,
`etatDeploiement` enum('demarrage','en_cours','operationnel','en_remplacement') NOT NULL,
`modeHebergement` enum('hds','on_premise','hybride'),
`modeFacturation` enum('saas','achat_maintenance','location'),
`interoperabilite` enum('non','oui_interface','oui_eai'),
`versionMajeure` varchar(50),
`commentaire` text,
`contactNom` varchar(255),
`contactFonction` varchar(255),
`contactEmail` varchar(320),
`saisiePar` int,
`createdAt` timestamp NOT NULL DEFAULT (now()),
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `logiciels_etablissements_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `solutions` (
`id` int AUTO_INCREMENT NOT NULL,
`nom` varchar(255) NOT NULL,
`editeurId` int NOT NULL,
`blocFonctionnelId` int,
`estValide` boolean NOT NULL DEFAULT true,
`createdAt` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `solutions_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
ALTER TABLE `users` ADD `sonumRole` enum('referent','gestionnaire') DEFAULT 'referent' NOT NULL;--> statement-breakpoint
ALTER TABLE `users` ADD `cguAccepted` boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE `users` ADD `cguAcceptedAt` timestamp;

View File

@@ -0,0 +1,110 @@
{
"version": "5",
"dialect": "mysql",
"id": "73e59d3e-6b7c-4c4b-b26e-ab4839c0f17d",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"openId": {
"name": "openId",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "varchar(320)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"loginMethod": {
"name": "loginMethod",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"role": {
"name": "role",
"type": "enum('user','admin')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'user'"
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
},
"lastSignedIn": {
"name": "lastSignedIn",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"users_id": {
"name": "users_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"users_openId_unique": {
"name": "users_openId_unique",
"columns": [
"openId"
]
}
},
"checkConstraint": {}
}
},
"views": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"tables": {},
"indexes": {}
}
}

View File

@@ -0,0 +1,677 @@
{
"version": "5",
"dialect": "mysql",
"id": "0242a5e6-a3ef-4093-887e-4d05cea98433",
"prevId": "73e59d3e-6b7c-4c4b-b26e-ab4839c0f17d",
"tables": {
"blocs_fonctionnels": {
"name": "blocs_fonctionnels",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"nom": {
"name": "nom",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"estValide": {
"name": "estValide",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"blocs_fonctionnels_id": {
"name": "blocs_fonctionnels_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"consultations": {
"name": "consultations",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"etablissementId": {
"name": "etablissementId",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"consultePar": {
"name": "consultePar",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"consulteParNom": {
"name": "consulteParNom",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"consultations_id": {
"name": "consultations_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"demandes_contact": {
"name": "demandes_contact",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"etablissementCibleId": {
"name": "etablissementCibleId",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"demandeurId": {
"name": "demandeurId",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"demandeurNom": {
"name": "demandeurNom",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"demandeurEmail": {
"name": "demandeurEmail",
"type": "varchar(320)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"message": {
"name": "message",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"statut": {
"name": "statut",
"type": "enum('en_attente','repondu','ferme')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'en_attente'"
},
"reponse": {
"name": "reponse",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"reponsePar": {
"name": "reponsePar",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"reponduAt": {
"name": "reponduAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"demandes_contact_id": {
"name": "demandes_contact_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"editeurs": {
"name": "editeurs",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"nom": {
"name": "nom",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"estValide": {
"name": "estValide",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"editeurs_id": {
"name": "editeurs_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"etablissements": {
"name": "etablissements",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"finess": {
"name": "finess",
"type": "varchar(20)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"nom": {
"name": "nom",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"region": {
"name": "region",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"departement": {
"name": "departement",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"typeActivite": {
"name": "typeActivite",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"tailleEffectifs": {
"name": "tailleEffectifs",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"referentId": {
"name": "referentId",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"visibilite": {
"name": "visibilite",
"type": "enum('tous','gestionnaires')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'tous'"
},
"accepteMiseEnRelation": {
"name": "accepteMiseEnRelation",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"etablissements_id": {
"name": "etablissements_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"logiciels_etablissements": {
"name": "logiciels_etablissements",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"etablissementId": {
"name": "etablissementId",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"solutionId": {
"name": "solutionId",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"etatDeploiement": {
"name": "etatDeploiement",
"type": "enum('demarrage','en_cours','operationnel','en_remplacement')",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"modeHebergement": {
"name": "modeHebergement",
"type": "enum('hds','on_premise','hybride')",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"modeFacturation": {
"name": "modeFacturation",
"type": "enum('saas','achat_maintenance','location')",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"interoperabilite": {
"name": "interoperabilite",
"type": "enum('non','oui_interface','oui_eai')",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"versionMajeure": {
"name": "versionMajeure",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"commentaire": {
"name": "commentaire",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"contactNom": {
"name": "contactNom",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"contactFonction": {
"name": "contactFonction",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"contactEmail": {
"name": "contactEmail",
"type": "varchar(320)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"saisiePar": {
"name": "saisiePar",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"logiciels_etablissements_id": {
"name": "logiciels_etablissements_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"solutions": {
"name": "solutions",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"nom": {
"name": "nom",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"editeurId": {
"name": "editeurId",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"blocFonctionnelId": {
"name": "blocFonctionnelId",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"estValide": {
"name": "estValide",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"solutions_id": {
"name": "solutions_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"openId": {
"name": "openId",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "varchar(320)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"loginMethod": {
"name": "loginMethod",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"role": {
"name": "role",
"type": "enum('user','admin')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'user'"
},
"sonumRole": {
"name": "sonumRole",
"type": "enum('referent','gestionnaire')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'referent'"
},
"cguAccepted": {
"name": "cguAccepted",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"cguAcceptedAt": {
"name": "cguAcceptedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
},
"lastSignedIn": {
"name": "lastSignedIn",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"users_id": {
"name": "users_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"users_openId_unique": {
"name": "users_openId_unique",
"columns": [
"openId"
]
}
},
"checkConstraint": {}
}
},
"views": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"tables": {},
"indexes": {}
}
}

View File

@@ -1,5 +1,20 @@
{
"version": "7",
"dialect": "mysql",
"entries": []
}
"entries": [
{
"idx": 0,
"version": "5",
"when": 1776268420114,
"tag": "0000_complete_luminals",
"breakpoints": true
},
{
"idx": 1,
"version": "5",
"when": 1776268633192,
"tag": "0001_wide_frightful_four",
"breakpoints": true
}
]
}

View File

@@ -1,22 +1,27 @@
import { int, mysqlEnum, mysqlTable, text, timestamp, varchar } from "drizzle-orm/mysql-core";
import {
boolean,
int,
mysqlEnum,
mysqlTable,
text,
timestamp,
varchar,
} from "drizzle-orm/mysql-core";
// ─── Utilisateurs ────────────────────────────────────────────────────────────
/**
* Core user table backing auth flow.
* Extend this file with additional tables as your product grows.
* Columns use camelCase to match both database fields and generated types.
*/
export const users = mysqlTable("users", {
/**
* Surrogate primary key. Auto-incremented numeric value managed by the database.
* Use this for relations between tables.
*/
id: int("id").autoincrement().primaryKey(),
/** Manus OAuth identifier (openId) returned from the OAuth callback. Unique per user. */
openId: varchar("openId", { length: 64 }).notNull().unique(),
name: text("name"),
email: varchar("email", { length: 320 }),
loginMethod: varchar("loginMethod", { length: 64 }),
role: mysqlEnum("role", ["user", "admin"]).default("user").notNull(),
// Profil SONUM : referent = référent numérique, gestionnaire = gestionnaire SONUM
sonumRole: mysqlEnum("sonumRole", ["referent", "gestionnaire"]).default("referent").notNull(),
// CGU acceptée
cguAccepted: boolean("cguAccepted").default(false).notNull(),
cguAcceptedAt: timestamp("cguAcceptedAt"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
lastSignedIn: timestamp("lastSignedIn").defaultNow().notNull(),
@@ -25,4 +30,130 @@ export const users = mysqlTable("users", {
export type User = typeof users.$inferSelect;
export type InsertUser = typeof users.$inferInsert;
// TODO: Add your tables here
// ─── Référentiel : Éditeurs ───────────────────────────────────────────────────
export const editeurs = mysqlTable("editeurs", {
id: int("id").autoincrement().primaryKey(),
nom: varchar("nom", { length: 255 }).notNull(),
estValide: boolean("estValide").default(true).notNull(), // false = ajouté par un référent, en attente de validation FEHAP
createdAt: timestamp("createdAt").defaultNow().notNull(),
});
export type Editeur = typeof editeurs.$inferSelect;
// ─── Référentiel : Blocs Fonctionnels ────────────────────────────────────────
export const blocsFonctionnels = mysqlTable("blocs_fonctionnels", {
id: int("id").autoincrement().primaryKey(),
nom: varchar("nom", { length: 255 }).notNull(),
estValide: boolean("estValide").default(true).notNull(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
});
export type BlocFonctionnel = typeof blocsFonctionnels.$inferSelect;
// ─── Référentiel : Solutions ──────────────────────────────────────────────────
export const solutions = mysqlTable("solutions", {
id: int("id").autoincrement().primaryKey(),
nom: varchar("nom", { length: 255 }).notNull(),
editeurId: int("editeurId").notNull(),
blocFonctionnelId: int("blocFonctionnelId"),
estValide: boolean("estValide").default(true).notNull(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
});
export type Solution = typeof solutions.$inferSelect;
// ─── Établissements ───────────────────────────────────────────────────────────
export const etablissements = mysqlTable("etablissements", {
id: int("id").autoincrement().primaryKey(),
finess: varchar("finess", { length: 20 }),
nom: varchar("nom", { length: 255 }).notNull(),
region: varchar("region", { length: 100 }),
departement: varchar("departement", { length: 100 }),
typeActivite: varchar("typeActivite", { length: 100 }),
tailleEffectifs: varchar("tailleEffectifs", { length: 50 }),
// Référent numérique responsable
referentId: int("referentId"),
// Visibilité : "tous" = visible par tous les référents, "gestionnaires" = visible uniquement par gestionnaires SONUM
visibilite: mysqlEnum("visibilite", ["tous", "gestionnaires"]).default("tous").notNull(),
// Acceptation mise en relation
accepteMiseEnRelation: boolean("accepteMiseEnRelation").default(true).notNull(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
});
export type Etablissement = typeof etablissements.$inferSelect;
// ─── Logiciels par Établissement ─────────────────────────────────────────────
export const logicielsEtablissements = mysqlTable("logiciels_etablissements", {
id: int("id").autoincrement().primaryKey(),
etablissementId: int("etablissementId").notNull(),
solutionId: int("solutionId").notNull(),
etatDeploiement: mysqlEnum("etatDeploiement", [
"demarrage",
"en_cours",
"operationnel",
"en_remplacement",
]).notNull(),
modeHebergement: mysqlEnum("modeHebergement", [
"hds",
"on_premise",
"hybride",
]),
modeFacturation: mysqlEnum("modeFacturation", [
"saas",
"achat_maintenance",
"location",
]),
interoperabilite: mysqlEnum("interoperabilite", ["non", "oui_interface", "oui_eai"]),
versionMajeure: varchar("versionMajeure", { length: 50 }),
commentaire: text("commentaire"),
// Contact référent pour ce logiciel
contactNom: varchar("contactNom", { length: 255 }),
contactFonction: varchar("contactFonction", { length: 255 }),
contactEmail: varchar("contactEmail", { length: 320 }),
saisiePar: int("saisiePar"), // userId
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
});
export type LogicielEtablissement = typeof logicielsEtablissements.$inferSelect;
// ─── Traçabilité : Consultations ──────────────────────────────────────────────
export const consultations = mysqlTable("consultations", {
id: int("id").autoincrement().primaryKey(),
etablissementId: int("etablissementId").notNull(),
consultePar: int("consultePar").notNull(), // userId
consultéParNom: varchar("consulteParNom", { length: 255 }),
createdAt: timestamp("createdAt").defaultNow().notNull(),
});
export type Consultation = typeof consultations.$inferSelect;
// ─── Demandes de Contact ──────────────────────────────────────────────────────
export const demandesContact = mysqlTable("demandes_contact", {
id: int("id").autoincrement().primaryKey(),
// Établissement cible
etablissementCibleId: int("etablissementCibleId").notNull(),
// Demandeur
demandeurId: int("demandeurId").notNull(),
demandeurNom: varchar("demandeurNom", { length: 255 }),
demandeurEmail: varchar("demandeurEmail", { length: 320 }),
// Message
message: text("message").notNull(),
// Statut
statut: mysqlEnum("statut", ["en_attente", "repondu", "ferme"]).default("en_attente").notNull(),
reponse: text("reponse"),
reponsePar: int("reponsePar"),
reponduAt: timestamp("reponduAt"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
});
export type DemandeContact = typeof demandesContact.$inferSelect;

71
scripts/seed.mjs Normal file
View File

@@ -0,0 +1,71 @@
import mysql from "mysql2/promise";
import * as dotenv from "dotenv";
dotenv.config();
const BLOCS = [
"SI RH","SI Finance","SI Métier","SI Collaboratif","Site internet",
"SI Achat","SI Support","SI Infrastructure","SI Sécurité","SI Qualité",
"SI Immobilier","SI Décisionnel"
];
const EDITEURS_DEMO = [
"Cerner","Oracle Health","Dedalus","Mediware","Infor","SAP","Microsoft",
"Sage","Cegid","Berger-Levrault","Maincare","Hopital Manager","Softway Medical",
"Medasys","Enovacom","Xperis","Mipih","Sephira","Cegedim","Autre"
];
const SOLUTIONS_DEMO = [
{ nom: "DPI (Dossier Patient Informatisé)", editeur: "Dedalus", bloc: "SI Métier" },
{ nom: "SIRH", editeur: "Cegid", bloc: "SI RH" },
{ nom: "Logiciel de paie", editeur: "Sage", bloc: "SI RH" },
{ nom: "Comptabilité générale", editeur: "Sage", bloc: "SI Finance" },
{ nom: "Messagerie sécurisée", editeur: "Enovacom", bloc: "SI Collaboratif" },
{ nom: "Intranet", editeur: "Microsoft", bloc: "Site internet" },
{ nom: "GMAO", editeur: "Infor", bloc: "SI Immobilier" },
{ nom: "Système de gestion des achats", editeur: "Berger-Levrault", bloc: "SI Achat" },
{ nom: "BI / Reporting", editeur: "Microsoft", bloc: "SI Décisionnel" },
{ nom: "Antivirus / EDR", editeur: "Microsoft", bloc: "SI Sécurité" },
];
const ETABLISSEMENTS_DEMO = [
{ nom: "Clinique Saint-Joseph", finess: "750123456", region: "Île-de-France", typeActivite: "MCO", tailleEffectifs: "200 - 500 salariés" },
{ nom: "EHPAD Les Jardins", finess: "690234567", region: "Auvergne-Rhône-Alpes", typeActivite: "EHPAD", tailleEffectifs: "50 - 200 salariés" },
{ nom: "Centre Hospitalier de Bretagne", finess: "290345678", region: "Bretagne", typeActivite: "MCO", tailleEffectifs: "500 - 1000 salariés" },
{ nom: "HAD Nouvelle-Aquitaine", finess: "330456789", region: "Nouvelle-Aquitaine", typeActivite: "HAD", tailleEffectifs: "< 50 salariés" },
{ nom: "IME Les Pins", finess: "130567890", region: "Provence-Alpes-Côte d'Azur", typeActivite: "IME", tailleEffectifs: "50 - 200 salariés" },
];
async function seed() {
const conn = await mysql.createConnection(process.env.DATABASE_URL);
console.log("Seeding blocs fonctionnels...");
for (const nom of BLOCS) {
await conn.execute("INSERT IGNORE INTO blocs_fonctionnels (nom, estValide) VALUES (?, 1)", [nom]);
}
console.log("Seeding editeurs...");
for (const nom of EDITEURS_DEMO) {
await conn.execute("INSERT IGNORE INTO editeurs (nom, estValide) VALUES (?, 1)", [nom]);
}
console.log("Seeding solutions...");
for (const s of SOLUTIONS_DEMO) {
const [editeurRows] = await conn.execute("SELECT id FROM editeurs WHERE nom = ? LIMIT 1", [s.editeur]);
const [blocRows] = await conn.execute("SELECT id FROM blocs_fonctionnels WHERE nom = ? LIMIT 1", [s.bloc]);
const editeurId = editeurRows[0]?.id;
const blocId = blocRows[0]?.id;
if (editeurId) {
await conn.execute(
"INSERT IGNORE INTO solutions (nom, editeurId, blocFonctionnelId, estValide) VALUES (?, ?, ?, 1)",
[s.nom, editeurId, blocId ?? null]
);
}
}
console.log("Seeding etablissements demo...");
for (const e of ETABLISSEMENTS_DEMO) {
await conn.execute(
"INSERT IGNORE INTO etablissements (finess, nom, region, typeActivite, tailleEffectifs, visibilite, accepteMiseEnRelation) VALUES (?, ?, ?, ?, ?, 'tous', 1)",
[e.finess, e.nom, e.region, e.typeActivite, e.tailleEffectifs]
);
}
console.log("Seed termine !");
await conn.end();
}
seed().catch(console.error);

View File

@@ -1,11 +1,20 @@
import { eq } from "drizzle-orm";
import { and, desc, eq, ilike, like, or, sql } from "drizzle-orm";
import { drizzle } from "drizzle-orm/mysql2";
import { InsertUser, users } from "../drizzle/schema";
import { ENV } from './_core/env';
import {
InsertUser,
blocsFonctionnels,
consultations,
demandesContact,
editeurs,
etablissements,
logicielsEtablissements,
solutions,
users,
} from "../drizzle/schema";
import { ENV } from "./_core/env";
let _db: ReturnType<typeof drizzle> | null = null;
// Lazily create the drizzle instance so local tooling can run without a DB.
export async function getDb() {
if (!_db && process.env.DATABASE_URL) {
try {
@@ -18,75 +27,366 @@ export async function getDb() {
return _db;
}
// ─── Utilisateurs ─────────────────────────────────────────────────────────────
export async function upsertUser(user: InsertUser): Promise<void> {
if (!user.openId) {
throw new Error("User openId is required for upsert");
}
if (!user.openId) throw new Error("User openId is required for upsert");
const db = await getDb();
if (!db) {
console.warn("[Database] Cannot upsert user: database not available");
return;
if (!db) { console.warn("[Database] Cannot upsert user: database not available"); return; }
const values: InsertUser = { openId: user.openId };
const updateSet: Record<string, unknown> = {};
const textFields = ["name", "email", "loginMethod"] as const;
for (const field of textFields) {
const value = user[field];
if (value === undefined) continue;
const normalized = value ?? null;
values[field] = normalized;
updateSet[field] = normalized;
}
try {
const values: InsertUser = {
openId: user.openId,
};
const updateSet: Record<string, unknown> = {};
if (user.lastSignedIn !== undefined) { values.lastSignedIn = user.lastSignedIn; updateSet.lastSignedIn = user.lastSignedIn; }
if (user.role !== undefined) { values.role = user.role; updateSet.role = user.role; }
else if (user.openId === ENV.ownerOpenId) { values.role = "admin"; updateSet.role = "admin"; }
const textFields = ["name", "email", "loginMethod"] as const;
type TextField = (typeof textFields)[number];
if (!values.lastSignedIn) values.lastSignedIn = new Date();
if (Object.keys(updateSet).length === 0) updateSet.lastSignedIn = new Date();
const assignNullable = (field: TextField) => {
const value = user[field];
if (value === undefined) return;
const normalized = value ?? null;
values[field] = normalized;
updateSet[field] = normalized;
};
textFields.forEach(assignNullable);
if (user.lastSignedIn !== undefined) {
values.lastSignedIn = user.lastSignedIn;
updateSet.lastSignedIn = user.lastSignedIn;
}
if (user.role !== undefined) {
values.role = user.role;
updateSet.role = user.role;
} else if (user.openId === ENV.ownerOpenId) {
values.role = 'admin';
updateSet.role = 'admin';
}
if (!values.lastSignedIn) {
values.lastSignedIn = new Date();
}
if (Object.keys(updateSet).length === 0) {
updateSet.lastSignedIn = new Date();
}
await db.insert(users).values(values).onDuplicateKeyUpdate({
set: updateSet,
});
} catch (error) {
console.error("[Database] Failed to upsert user:", error);
throw error;
}
await db.insert(users).values(values).onDuplicateKeyUpdate({ set: updateSet });
}
export async function getUserByOpenId(openId: string) {
const db = await getDb();
if (!db) {
console.warn("[Database] Cannot get user: database not available");
return undefined;
}
if (!db) return undefined;
const result = await db.select().from(users).where(eq(users.openId, openId)).limit(1);
return result.length > 0 ? result[0] : undefined;
}
// TODO: add feature queries here as your schema grows.
export async function updateUserCgu(userId: number) {
const db = await getDb();
if (!db) return;
await db.update(users).set({ cguAccepted: true, cguAcceptedAt: new Date() }).where(eq(users.id, userId));
}
export async function updateUserSonumRole(userId: number, sonumRole: "referent" | "gestionnaire") {
const db = await getDb();
if (!db) return;
await db.update(users).set({ sonumRole }).where(eq(users.id, userId));
}
export async function getAllUsers() {
const db = await getDb();
if (!db) return [];
return db.select().from(users).orderBy(desc(users.createdAt));
}
// ─── Référentiel ──────────────────────────────────────────────────────────────
export async function getEditeurs() {
const db = await getDb();
if (!db) return [];
return db.select().from(editeurs).where(eq(editeurs.estValide, true)).orderBy(editeurs.nom);
}
export async function createEditeur(nom: string, estValide = false) {
const db = await getDb();
if (!db) return null;
const result = await db.insert(editeurs).values({ nom, estValide });
return result[0];
}
export async function getBlocsFonctionnels() {
const db = await getDb();
if (!db) return [];
return db.select().from(blocsFonctionnels).where(eq(blocsFonctionnels.estValide, true)).orderBy(blocsFonctionnels.nom);
}
export async function createBlocFonctionnel(nom: string, estValide = false) {
const db = await getDb();
if (!db) return null;
const result = await db.insert(blocsFonctionnels).values({ nom, estValide });
return result[0];
}
export async function getSolutions(search?: string) {
const db = await getDb();
if (!db) return [];
const query = db
.select({
id: solutions.id,
nom: solutions.nom,
editeurId: solutions.editeurId,
editeurNom: editeurs.nom,
blocFonctionnelId: solutions.blocFonctionnelId,
blocFonctionnelNom: blocsFonctionnels.nom,
estValide: solutions.estValide,
})
.from(solutions)
.leftJoin(editeurs, eq(solutions.editeurId, editeurs.id))
.leftJoin(blocsFonctionnels, eq(solutions.blocFonctionnelId, blocsFonctionnels.id))
.where(
search
? and(eq(solutions.estValide, true), or(like(solutions.nom, `%${search}%`), like(editeurs.nom, `%${search}%`)))
: eq(solutions.estValide, true)
)
.orderBy(solutions.nom);
return query;
}
export async function createSolution(nom: string, editeurId: number, blocFonctionnelId?: number | null, estValide = false) {
const db = await getDb();
if (!db) return null;
const result = await db.insert(solutions).values({ nom, editeurId, blocFonctionnelId: blocFonctionnelId ?? null, estValide });
return result[0];
}
// ─── Établissements ───────────────────────────────────────────────────────────
export async function getEtablissementsByReferent(referentId: number) {
const db = await getDb();
if (!db) return [];
return db.select().from(etablissements).where(eq(etablissements.referentId, referentId)).orderBy(etablissements.nom);
}
export async function getAllEtablissements() {
const db = await getDb();
if (!db) return [];
return db.select().from(etablissements).orderBy(etablissements.nom);
}
export async function getEtablissementById(id: number) {
const db = await getDb();
if (!db) return null;
const result = await db.select().from(etablissements).where(eq(etablissements.id, id)).limit(1);
return result[0] ?? null;
}
export async function searchEtablissements(filters: {
solutionId?: number;
editeurId?: number;
blocFonctionnelId?: number;
region?: string;
typeActivite?: string;
tailleEffectifs?: string;
etatDeploiement?: string;
userId?: number;
sonumRole?: string;
}) {
const db = await getDb();
if (!db) return [];
const conditions = [];
// Visibilité : si pas gestionnaire, on ne montre que les fiches "tous"
if (filters.sonumRole !== "gestionnaire") {
conditions.push(eq(etablissements.visibilite, "tous"));
}
if (filters.region) conditions.push(eq(etablissements.region, filters.region));
if (filters.typeActivite) conditions.push(eq(etablissements.typeActivite, filters.typeActivite));
if (filters.tailleEffectifs) conditions.push(eq(etablissements.tailleEffectifs, filters.tailleEffectifs));
let query = db
.select({
id: etablissements.id,
finess: etablissements.finess,
nom: etablissements.nom,
region: etablissements.region,
departement: etablissements.departement,
typeActivite: etablissements.typeActivite,
tailleEffectifs: etablissements.tailleEffectifs,
referentId: etablissements.referentId,
visibilite: etablissements.visibilite,
accepteMiseEnRelation: etablissements.accepteMiseEnRelation,
})
.from(etablissements)
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(etablissements.nom);
return query;
}
// ─── Logiciels par Établissement ─────────────────────────────────────────────
export async function getLogicielsByEtablissement(etablissementId: number) {
const db = await getDb();
if (!db) return [];
return db
.select({
id: logicielsEtablissements.id,
etablissementId: logicielsEtablissements.etablissementId,
solutionId: logicielsEtablissements.solutionId,
solutionNom: solutions.nom,
editeurNom: editeurs.nom,
blocFonctionnelNom: blocsFonctionnels.nom,
etatDeploiement: logicielsEtablissements.etatDeploiement,
modeHebergement: logicielsEtablissements.modeHebergement,
modeFacturation: logicielsEtablissements.modeFacturation,
interoperabilite: logicielsEtablissements.interoperabilite,
versionMajeure: logicielsEtablissements.versionMajeure,
commentaire: logicielsEtablissements.commentaire,
contactNom: logicielsEtablissements.contactNom,
contactFonction: logicielsEtablissements.contactFonction,
contactEmail: logicielsEtablissements.contactEmail,
createdAt: logicielsEtablissements.createdAt,
updatedAt: logicielsEtablissements.updatedAt,
})
.from(logicielsEtablissements)
.leftJoin(solutions, eq(logicielsEtablissements.solutionId, solutions.id))
.leftJoin(editeurs, eq(solutions.editeurId, editeurs.id))
.leftJoin(blocsFonctionnels, eq(solutions.blocFonctionnelId, blocsFonctionnels.id))
.where(eq(logicielsEtablissements.etablissementId, etablissementId))
.orderBy(logicielsEtablissements.createdAt);
}
export async function upsertLogicielEtablissement(data: {
id?: number;
etablissementId: number;
solutionId: number;
etatDeploiement: "demarrage" | "en_cours" | "operationnel" | "en_remplacement";
modeHebergement?: "hds" | "on_premise" | "hybride" | null;
modeFacturation?: "saas" | "achat_maintenance" | "location" | null;
interoperabilite?: "non" | "oui_interface" | "oui_eai" | null;
versionMajeure?: string | null;
commentaire?: string | null;
contactNom?: string | null;
contactFonction?: string | null;
contactEmail?: string | null;
saisiePar?: number;
}) {
const db = await getDb();
if (!db) return null;
if (data.id) {
await db.update(logicielsEtablissements).set({ ...data, updatedAt: new Date() }).where(eq(logicielsEtablissements.id, data.id));
return data.id;
}
const result = await db.insert(logicielsEtablissements).values(data);
return result[0];
}
export async function deleteLogicielEtablissement(id: number) {
const db = await getDb();
if (!db) return;
await db.delete(logicielsEtablissements).where(eq(logicielsEtablissements.id, id));
}
// ─── Traçabilité ──────────────────────────────────────────────────────────────
export async function recordConsultation(etablissementId: number, userId: number, userName: string) {
const db = await getDb();
if (!db) return;
await db.insert(consultations).values({ etablissementId, consultePar: userId, consultéParNom: userName });
}
export async function getConsultationCount(etablissementId: number) {
const db = await getDb();
if (!db) return 0;
const result = await db
.select({ count: sql<number>`count(*)` })
.from(consultations)
.where(eq(consultations.etablissementId, etablissementId));
return Number(result[0]?.count ?? 0);
}
export async function getConsultationsList(etablissementId: number) {
const db = await getDb();
if (!db) return [];
return db
.select()
.from(consultations)
.where(eq(consultations.etablissementId, etablissementId))
.orderBy(desc(consultations.createdAt))
.limit(50);
}
// ─── Demandes de Contact ──────────────────────────────────────────────────────
export async function createDemandeContact(data: {
etablissementCibleId: number;
demandeurId: number;
demandeurNom: string;
demandeurEmail: string;
message: string;
}) {
const db = await getDb();
if (!db) return null;
const result = await db.insert(demandesContact).values(data);
return result[0];
}
export async function getDemandesByDemandeur(demandeurId: number) {
const db = await getDb();
if (!db) return [];
return db
.select({
id: demandesContact.id,
etablissementNom: etablissements.nom,
message: demandesContact.message,
statut: demandesContact.statut,
reponse: demandesContact.reponse,
reponduAt: demandesContact.reponduAt,
createdAt: demandesContact.createdAt,
})
.from(demandesContact)
.leftJoin(etablissements, eq(demandesContact.etablissementCibleId, etablissements.id))
.where(eq(demandesContact.demandeurId, demandeurId))
.orderBy(desc(demandesContact.createdAt));
}
export async function getDemandesRecuesParEtablissement(referentId: number) {
const db = await getDb();
if (!db) return [];
return db
.select({
id: demandesContact.id,
etablissementNom: etablissements.nom,
demandeurNom: demandesContact.demandeurNom,
demandeurEmail: demandesContact.demandeurEmail,
message: demandesContact.message,
statut: demandesContact.statut,
reponse: demandesContact.reponse,
reponduAt: demandesContact.reponduAt,
createdAt: demandesContact.createdAt,
})
.from(demandesContact)
.leftJoin(etablissements, eq(demandesContact.etablissementCibleId, etablissements.id))
.where(eq(etablissements.referentId, referentId))
.orderBy(desc(demandesContact.createdAt));
}
export async function getAllDemandes() {
const db = await getDb();
if (!db) return [];
return db
.select({
id: demandesContact.id,
etablissementNom: etablissements.nom,
demandeurNom: demandesContact.demandeurNom,
demandeurEmail: demandesContact.demandeurEmail,
message: demandesContact.message,
statut: demandesContact.statut,
reponse: demandesContact.reponse,
reponduAt: demandesContact.reponduAt,
createdAt: demandesContact.createdAt,
})
.from(demandesContact)
.leftJoin(etablissements, eq(demandesContact.etablissementCibleId, etablissements.id))
.orderBy(desc(demandesContact.createdAt));
}
export async function repondreDemandeContact(id: number, reponse: string, reponsePar: number) {
const db = await getDb();
if (!db) return;
await db
.update(demandesContact)
.set({ reponse, reponsePar, statut: "repondu", reponduAt: new Date(), updatedAt: new Date() })
.where(eq(demandesContact.id, id));
}
export async function getDemandeById(id: number) {
const db = await getDb();
if (!db) return null;
const result = await db.select().from(demandesContact).where(eq(demandesContact.id, id)).limit(1);
return result[0] ?? null;
}

View File

@@ -1,28 +1,338 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import {
createBlocFonctionnel,
createDemandeContact,
createEditeur,
createSolution,
deleteLogicielEtablissement,
getAllDemandes,
getAllEtablissements,
getAllUsers,
getBlocsFonctionnels,
getConsultationCount,
getConsultationsList,
getDemandeById,
getDemandesByDemandeur,
getDemandesRecuesParEtablissement,
getEditeurs,
getEtablissementById,
getEtablissementsByReferent,
getLogicielsByEtablissement,
getSolutions,
recordConsultation,
repondreDemandeContact,
updateUserCgu,
updateUserSonumRole,
upsertLogicielEtablissement,
upsertUser,
} from "./db";
import { COOKIE_NAME } from "@shared/const";
import { getSessionCookieOptions } from "./_core/cookies";
import { systemRouter } from "./_core/systemRouter";
import { publicProcedure, router } from "./_core/trpc";
import { protectedProcedure, publicProcedure, router } from "./_core/trpc";
import { notifyOwner } from "./_core/notification";
import { getDb } from "./db";
import { etablissements } from "../drizzle/schema";
import { eq } from "drizzle-orm";
// ─── Middleware gestionnaire SONUM ────────────────────────────────────────────
const gestionnaireProcedure = protectedProcedure.use(({ ctx, next }) => {
if (ctx.user.sonumRole !== "gestionnaire" && ctx.user.role !== "admin") {
throw new TRPCError({ code: "FORBIDDEN", message: "Accès réservé aux gestionnaires SONUM" });
}
return next({ ctx });
});
// ─── Router principal ─────────────────────────────────────────────────────────
export const appRouter = router({
// if you need to use socket.io, read and register route in server/_core/index.ts, all api should start with '/api/' so that the gateway can route correctly
system: systemRouter,
// ─── Auth ──────────────────────────────────────────────────────────────────
auth: router({
me: publicProcedure.query(opts => opts.ctx.user),
me: publicProcedure.query((opts) => opts.ctx.user),
logout: publicProcedure.mutation(({ ctx }) => {
const cookieOptions = getSessionCookieOptions(ctx.req);
ctx.res.clearCookie(COOKIE_NAME, { ...cookieOptions, maxAge: -1 });
return {
success: true,
} as const;
return { success: true } as const;
}),
}),
// TODO: add feature routers here, e.g.
// todo: router({
// list: protectedProcedure.query(({ ctx }) =>
// db.getUserTodos(ctx.user.id)
// ),
// }),
// ─── CGU ───────────────────────────────────────────────────────────────────
cgu: router({
accept: protectedProcedure.mutation(async ({ ctx }) => {
await updateUserCgu(ctx.user.id);
return { success: true };
}),
status: protectedProcedure.query(({ ctx }) => ({
accepted: ctx.user.cguAccepted,
acceptedAt: ctx.user.cguAcceptedAt,
})),
}),
// ─── Référentiel ───────────────────────────────────────────────────────────
referentiel: router({
editeurs: publicProcedure.query(() => getEditeurs()),
blocsFonctionnels: publicProcedure.query(() => getBlocsFonctionnels()),
solutions: publicProcedure
.input(z.object({ search: z.string().optional() }))
.query(({ input }) => getSolutions(input.search)),
createEditeur: protectedProcedure
.input(z.object({ nom: z.string().min(1) }))
.mutation(async ({ input, ctx }) => {
const isGestionnaire = ctx.user.sonumRole === "gestionnaire" || ctx.user.role === "admin";
return createEditeur(input.nom, isGestionnaire);
}),
createBlocFonctionnel: protectedProcedure
.input(z.object({ nom: z.string().min(1) }))
.mutation(async ({ input, ctx }) => {
const isGestionnaire = ctx.user.sonumRole === "gestionnaire" || ctx.user.role === "admin";
return createBlocFonctionnel(input.nom, isGestionnaire);
}),
createSolution: protectedProcedure
.input(z.object({
nom: z.string().min(1),
editeurId: z.number().int(),
blocFonctionnelId: z.number().int().optional().nullable(),
}))
.mutation(async ({ input, ctx }) => {
const isGestionnaire = ctx.user.sonumRole === "gestionnaire" || ctx.user.role === "admin";
return createSolution(input.nom, input.editeurId, input.blocFonctionnelId, isGestionnaire);
}),
}),
// ─── Établissements ────────────────────────────────────────────────────────
etablissements: router({
mesEtablissements: protectedProcedure.query(({ ctx }) =>
getEtablissementsByReferent(ctx.user.id)
),
all: gestionnaireProcedure.query(() => getAllEtablissements()),
byId: protectedProcedure
.input(z.object({ id: z.number().int() }))
.query(async ({ input, ctx }) => {
const etab = await getEtablissementById(input.id);
if (!etab) throw new TRPCError({ code: "NOT_FOUND" });
// Vérifier visibilité
if (etab.visibilite === "gestionnaires" && ctx.user.sonumRole !== "gestionnaire" && ctx.user.role !== "admin") {
if (etab.referentId !== ctx.user.id) {
throw new TRPCError({ code: "FORBIDDEN" });
}
}
return etab;
}),
search: protectedProcedure
.input(z.object({
solutionId: z.number().int().optional(),
editeurId: z.number().int().optional(),
blocFonctionnelId: z.number().int().optional(),
region: z.string().optional(),
typeActivite: z.string().optional(),
tailleEffectifs: z.string().optional(),
etatDeploiement: z.string().optional(),
}))
.query(({ input, ctx }) => {
return import("./db").then(({ searchEtablissements }) =>
searchEtablissements({
...input,
userId: ctx.user.id,
sonumRole: ctx.user.sonumRole,
})
);
}),
create: gestionnaireProcedure
.input(z.object({
finess: z.string().optional(),
nom: z.string().min(1),
region: z.string().optional(),
departement: z.string().optional(),
typeActivite: z.string().optional(),
tailleEffectifs: z.string().optional(),
referentId: z.number().int().optional(),
}))
.mutation(async ({ input }) => {
const db = await getDb();
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
const result = await db.insert(etablissements).values(input);
return result[0];
}),
update: protectedProcedure
.input(z.object({
id: z.number().int(),
visibilite: z.enum(["tous", "gestionnaires"]).optional(),
accepteMiseEnRelation: z.boolean().optional(),
}))
.mutation(async ({ input, ctx }) => {
const etab = await getEtablissementById(input.id);
if (!etab) throw new TRPCError({ code: "NOT_FOUND" });
if (etab.referentId !== ctx.user.id && ctx.user.sonumRole !== "gestionnaire" && ctx.user.role !== "admin") {
throw new TRPCError({ code: "FORBIDDEN" });
}
const db = await getDb();
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
const { id, ...updateData } = input;
await db.update(etablissements).set(updateData).where(eq(etablissements.id, id));
return { success: true };
}),
}),
// ─── Logiciels ─────────────────────────────────────────────────────────────
logiciels: router({
byEtablissement: protectedProcedure
.input(z.object({ etablissementId: z.number().int() }))
.query(async ({ input, ctx }) => {
const etab = await getEtablissementById(input.etablissementId);
if (!etab) throw new TRPCError({ code: "NOT_FOUND" });
if (etab.visibilite === "gestionnaires" && ctx.user.sonumRole !== "gestionnaire" && ctx.user.role !== "admin") {
if (etab.referentId !== ctx.user.id) throw new TRPCError({ code: "FORBIDDEN" });
}
return getLogicielsByEtablissement(input.etablissementId);
}),
upsert: protectedProcedure
.input(z.object({
id: z.number().int().optional(),
etablissementId: z.number().int(),
solutionId: z.number().int(),
etatDeploiement: z.enum(["demarrage", "en_cours", "operationnel", "en_remplacement"]),
modeHebergement: z.enum(["hds", "on_premise", "hybride"]).optional().nullable(),
modeFacturation: z.enum(["saas", "achat_maintenance", "location"]).optional().nullable(),
interoperabilite: z.enum(["non", "oui_interface", "oui_eai"]).optional().nullable(),
versionMajeure: z.string().optional().nullable(),
commentaire: z.string().optional().nullable(),
contactNom: z.string().optional().nullable(),
contactFonction: z.string().optional().nullable(),
contactEmail: z.string().optional().nullable(),
}))
.mutation(async ({ input, ctx }) => {
const etab = await getEtablissementById(input.etablissementId);
if (!etab) throw new TRPCError({ code: "NOT_FOUND" });
if (etab.referentId !== ctx.user.id && ctx.user.sonumRole !== "gestionnaire" && ctx.user.role !== "admin") {
throw new TRPCError({ code: "FORBIDDEN" });
}
return upsertLogicielEtablissement({ ...input, saisiePar: ctx.user.id });
}),
delete: protectedProcedure
.input(z.object({ id: z.number().int(), etablissementId: z.number().int() }))
.mutation(async ({ input, ctx }) => {
const etab = await getEtablissementById(input.etablissementId);
if (!etab) throw new TRPCError({ code: "NOT_FOUND" });
if (etab.referentId !== ctx.user.id && ctx.user.sonumRole !== "gestionnaire" && ctx.user.role !== "admin") {
throw new TRPCError({ code: "FORBIDDEN" });
}
await deleteLogicielEtablissement(input.id);
return { success: true };
}),
}),
// ─── Traçabilité ───────────────────────────────────────────────────────────
tracabilite: router({
enregistrerConsultation: protectedProcedure
.input(z.object({ etablissementId: z.number().int() }))
.mutation(async ({ input, ctx }) => {
await recordConsultation(input.etablissementId, ctx.user.id, ctx.user.name ?? "Inconnu");
return { success: true };
}),
compteur: protectedProcedure
.input(z.object({ etablissementId: z.number().int() }))
.query(async ({ input, ctx }) => {
const etab = await getEtablissementById(input.etablissementId);
if (!etab) throw new TRPCError({ code: "NOT_FOUND" });
const canSee = etab.referentId === ctx.user.id || ctx.user.sonumRole === "gestionnaire" || ctx.user.role === "admin";
if (!canSee) throw new TRPCError({ code: "FORBIDDEN" });
return { count: await getConsultationCount(input.etablissementId) };
}),
liste: protectedProcedure
.input(z.object({ etablissementId: z.number().int() }))
.query(async ({ input, ctx }) => {
const etab = await getEtablissementById(input.etablissementId);
if (!etab) throw new TRPCError({ code: "NOT_FOUND" });
const canSee = etab.referentId === ctx.user.id || ctx.user.sonumRole === "gestionnaire" || ctx.user.role === "admin";
if (!canSee) throw new TRPCError({ code: "FORBIDDEN" });
return getConsultationsList(input.etablissementId);
}),
}),
// ─── Demandes de Contact ───────────────────────────────────────────────────
contact: router({
envoyer: protectedProcedure
.input(z.object({
etablissementCibleId: z.number().int(),
message: z.string().min(1),
}))
.mutation(async ({ input, ctx }) => {
const etab = await getEtablissementById(input.etablissementCibleId);
if (!etab) throw new TRPCError({ code: "NOT_FOUND" });
await createDemandeContact({
etablissementCibleId: input.etablissementCibleId,
demandeurId: ctx.user.id,
demandeurNom: ctx.user.name ?? "Inconnu",
demandeurEmail: ctx.user.email ?? "",
message: input.message,
});
// Notification au propriétaire (gestionnaire SONUM)
await notifyOwner({
title: `Nouvelle demande de contact — ${etab.nom}`,
content: `${ctx.user.name} souhaite contacter le référent de ${etab.nom}.\n\nMessage : ${input.message}`,
});
return { success: true };
}),
mesDemandes: protectedProcedure.query(({ ctx }) =>
getDemandesByDemandeur(ctx.user.id)
),
demandesRecues: protectedProcedure.query(({ ctx }) =>
getDemandesRecuesParEtablissement(ctx.user.id)
),
toutesLesDemandes: gestionnaireProcedure.query(() => getAllDemandes()),
repondre: protectedProcedure
.input(z.object({
id: z.number().int(),
reponse: z.string().min(1),
}))
.mutation(async ({ input, ctx }) => {
const demande = await getDemandeById(input.id);
if (!demande) throw new TRPCError({ code: "NOT_FOUND" });
const etab = await getEtablissementById(demande.etablissementCibleId);
const canReply = (etab?.referentId === ctx.user.id) || ctx.user.sonumRole === "gestionnaire" || ctx.user.role === "admin";
if (!canReply) throw new TRPCError({ code: "FORBIDDEN" });
await repondreDemandeContact(input.id, input.reponse, ctx.user.id);
return { success: true };
}),
}),
// ─── Administration ────────────────────────────────────────────────────────
admin: router({
users: gestionnaireProcedure.query(() => getAllUsers()),
updateRole: gestionnaireProcedure
.input(z.object({
userId: z.number().int(),
sonumRole: z.enum(["referent", "gestionnaire"]),
}))
.mutation(async ({ input }) => {
await updateUserSonumRole(input.userId, input.sonumRole);
return { success: true };
}),
}),
});
export type AppRouter = typeof appRouter;

154
server/sonum.test.ts Normal file
View File

@@ -0,0 +1,154 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { appRouter } from "./routers";
import type { TrpcContext } from "./_core/context";
// ─── Helpers ──────────────────────────────────────────────────────────────────
function makeUser(overrides: Partial<TrpcContext["user"]> = {}): NonNullable<TrpcContext["user"]> {
return {
id: 1,
openId: "user-1",
name: "Référent Test",
email: "referent@test.fr",
loginMethod: "manus",
role: "user",
sonumRole: "referent",
cguAccepted: true,
cguAcceptedAt: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
lastSignedIn: new Date(),
...overrides,
};
}
function makeGestionnaire(): NonNullable<TrpcContext["user"]> {
return makeUser({ id: 2, openId: "gestionnaire-1", sonumRole: "gestionnaire", name: "Gestionnaire SONUM" });
}
function makeCtx(user: NonNullable<TrpcContext["user"]> | null = null): TrpcContext {
return {
user,
req: { protocol: "https", headers: {} } as TrpcContext["req"],
res: {
clearCookie: vi.fn(),
} as unknown as TrpcContext["res"],
};
}
// ─── Tests Auth ───────────────────────────────────────────────────────────────
describe("auth.logout", () => {
it("clears the session cookie and returns success", async () => {
const ctx = makeCtx(makeUser());
const caller = appRouter.createCaller(ctx);
const result = await caller.auth.logout();
expect(result).toEqual({ success: true });
});
it("works for unauthenticated users too (public procedure)", async () => {
const ctx = makeCtx(null);
const caller = appRouter.createCaller(ctx);
const result = await caller.auth.logout();
expect(result).toEqual({ success: true });
});
});
describe("auth.me", () => {
it("returns null for unauthenticated user", async () => {
const ctx = makeCtx(null);
const caller = appRouter.createCaller(ctx);
const result = await caller.auth.me();
expect(result).toBeNull();
});
it("returns user for authenticated user", async () => {
const user = makeUser();
const ctx = makeCtx(user);
const caller = appRouter.createCaller(ctx);
const result = await caller.auth.me();
expect(result?.id).toBe(1);
expect(result?.sonumRole).toBe("referent");
});
});
// ─── Tests CGU ────────────────────────────────────────────────────────────────
describe("cgu.status", () => {
it("throws UNAUTHORIZED for unauthenticated user", async () => {
const ctx = makeCtx(null);
const caller = appRouter.createCaller(ctx);
await expect(caller.cgu.status()).rejects.toThrow();
});
it("returns cgu status for authenticated user", async () => {
const user = makeUser({ cguAccepted: true });
const ctx = makeCtx(user);
const caller = appRouter.createCaller(ctx);
const result = await caller.cgu.status();
expect(result.accepted).toBe(true);
});
it("returns false when CGU not accepted", async () => {
const user = makeUser({ cguAccepted: false, cguAcceptedAt: undefined });
const ctx = makeCtx(user);
const caller = appRouter.createCaller(ctx);
const result = await caller.cgu.status();
expect(result.accepted).toBe(false);
});
});
// ─── Tests Gestion des rôles ──────────────────────────────────────────────────
describe("admin.users (gestionnaire only)", () => {
it("throws FORBIDDEN for a referent", async () => {
const ctx = makeCtx(makeUser());
const caller = appRouter.createCaller(ctx);
await expect(caller.admin.users()).rejects.toMatchObject({ code: "FORBIDDEN" });
});
it("throws UNAUTHORIZED for unauthenticated user", async () => {
const ctx = makeCtx(null);
const caller = appRouter.createCaller(ctx);
await expect(caller.admin.users()).rejects.toThrow();
});
});
describe("contact.toutesLesDemandes (gestionnaire only)", () => {
it("throws FORBIDDEN for a referent", async () => {
const ctx = makeCtx(makeUser());
const caller = appRouter.createCaller(ctx);
await expect(caller.contact.toutesLesDemandes()).rejects.toMatchObject({ code: "FORBIDDEN" });
});
});
// ─── Tests Référentiel ────────────────────────────────────────────────────────
describe("referentiel.editeurs", () => {
it("is accessible as a public procedure", async () => {
const ctx = makeCtx(null);
const caller = appRouter.createCaller(ctx);
// Should not throw (may return empty array if DB not available)
const result = await caller.referentiel.editeurs();
expect(Array.isArray(result)).toBe(true);
});
});
describe("referentiel.blocsFonctionnels", () => {
it("is accessible as a public procedure", async () => {
const ctx = makeCtx(null);
const caller = appRouter.createCaller(ctx);
const result = await caller.referentiel.blocsFonctionnels();
expect(Array.isArray(result)).toBe(true);
});
});
// ─── Tests Traçabilité ────────────────────────────────────────────────────────
describe("tracabilite.compteur", () => {
it("throws UNAUTHORIZED for unauthenticated user", async () => {
const ctx = makeCtx(null);
const caller = appRouter.createCaller(ctx);
await expect(caller.tracabilite.compteur({ etablissementId: 1 })).rejects.toThrow();
});
});

94
shared/referentiel.ts Normal file
View File

@@ -0,0 +1,94 @@
export const REGIONS = [
"Auvergne-Rhône-Alpes",
"Bourgogne-Franche-Comté",
"Bretagne",
"Centre-Val de Loire",
"Corse",
"Grand Est",
"Guadeloupe",
"Guyane",
"Hauts-de-France",
"Île-de-France",
"La Réunion",
"Martinique",
"Mayotte",
"Normandie",
"Nouvelle-Aquitaine",
"Occitanie",
"Pays de la Loire",
"Provence-Alpes-Côte d'Azur",
];
export const TYPES_ACTIVITE = [
"HAD",
"MCO",
"SMR",
"EHPAD",
"USLD",
"Psychiatrie",
"SSR",
"SSIAD",
"IME",
"FAM",
"MAS",
"CAMSP",
"CMPP",
"ESAT",
"Foyer de vie",
"Résidence autonomie",
"Autre",
];
export const TAILLES_EFFECTIFS = [
"< 50 salariés",
"50 - 200 salariés",
"200 - 500 salariés",
"500 - 1000 salariés",
"> 1000 salariés",
];
export const BLOCS_FONCTIONNELS_DEFAUT = [
"SI RH",
"SI Finance",
"SI Métier",
"SI Collaboratif",
"Site internet",
"SI Achat",
"SI Support",
"SI Infrastructure",
"SI Sécurité",
"SI Qualité",
"SI Immobilier",
"SI Décisionnel",
];
export const ETATS_DEPLOIEMENT: Record<string, string> = {
demarrage: "Démarrage",
en_cours: "En cours",
operationnel: "Opérationnel",
en_remplacement: "En remplacement",
};
export const MODES_HEBERGEMENT: Record<string, string> = {
hds: "Hébergement HDS",
on_premise: "On Premise",
hybride: "Hybride",
};
export const MODES_FACTURATION: Record<string, string> = {
saas: "SaaS",
achat_maintenance: "Achat + Maintenance",
location: "Location",
};
export const INTEROPERABILITE: Record<string, string> = {
non: "Non",
oui_interface: "Oui (via interface)",
oui_eai: "Oui (via EAI)",
};
export const STATUTS_DEMANDE: Record<string, string> = {
en_attente: "En attente",
repondu: "Répondu",
ferme: "Fermé",
};

33
todo.md Normal file
View File

@@ -0,0 +1,33 @@
# SONUM - Todo
## Base de données & Backend
- [x] Schéma DB : tables établissements, solutions, éditeurs, blocs fonctionnels, logiciels_etablissements
- [x] Schéma DB : tables demandes_contact, consultations (traçabilité), cgu_acceptances
- [x] Données de référence : régions, types d'activité, blocs fonctionnels, états déploiement, modes hébergement
- [x] Procédures tRPC : CRUD solutions et éditeurs
- [x] Procédures tRPC : CRUD établissements et rattachement logiciels
- [x] Procédures tRPC : moteur de recherche multicritères
- [x] Procédures tRPC : fiche logiciels + compteur consultation
- [x] Procédures tRPC : prise de contact (email + notification)
- [x] Procédures tRPC : demandes de contact (liste, réponse)
- [x] Procédures tRPC : CGU (vérification + acceptation)
- [x] Procédures tRPC : gestion des rôles (admin/user)
## Interface & Design
- [x] Design system : couleurs FEHAP, typographie, tokens CSS
- [x] Layout principal avec navigation (sidebar + header)
- [x] Page de connexion / landing
- [x] Modale Charte CGU (1ère connexion)
- [x] Page d'accueil : moteur de recherche multicritères
- [x] Page résultats de recherche (tableau triable)
- [x] Page "Mes Établissements" (accordéons)
- [x] Formulaire de saisie guidé en étapes (rattacher une solution)
- [x] Mode édition complète inline des tableaux
- [x] Page fiche logiciels d'un établissement
- [x] Modale prise de contact
- [x] Page "Mes Demandes de Contact"
- [x] Interface gestionnaire SONUM (accès admin)
- [x] Lien retour espace adhérent FEHAP
## Tests
- [x] Tests unitaires procédures tRPC (14 tests passés)