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:
@@ -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>
|
||||
|
||||
151
client/src/components/CguModal.tsx
Normal file
151
client/src/components/CguModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
96
client/src/components/ContactModal.tsx
Normal file
96
client/src/components/ContactModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
client/src/components/EtatBadge.tsx
Normal file
35
client/src/components/EtatBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
417
client/src/components/RattacherSolutionModal.tsx
Normal file
417
client/src/components/RattacherSolutionModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
240
client/src/components/SonumLayout.tsx
Normal file
240
client/src/components/SonumLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
283
client/src/pages/Admin.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
240
client/src/pages/FicheEtablissement.tsx
Normal file
240
client/src/pages/FicheEtablissement.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
import { useAuth } from "@/_core/hooks/useAuth";
|
||||
import { EtatBadge, HebergementBadge, FacturationBadge } from "@/components/EtatBadge";
|
||||
import SonumLayout from "@/components/SonumLayout";
|
||||
import ContactModal from "@/components/ContactModal";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Building2,
|
||||
Eye,
|
||||
Mail,
|
||||
MapPin,
|
||||
Phone,
|
||||
Server,
|
||||
Tag,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { INTEROPERABILITE } from "../../../shared/referentiel";
|
||||
|
||||
interface Props {
|
||||
params: { id: string };
|
||||
}
|
||||
|
||||
export default function FicheEtablissement({ params }: Props) {
|
||||
const id = Number(params.id);
|
||||
const { user } = useAuth();
|
||||
const [, navigate] = useLocation();
|
||||
const [showContact, setShowContact] = useState(false);
|
||||
|
||||
const etabQuery = trpc.etablissements.byId.useQuery({ id }, { enabled: !!id });
|
||||
const logicielsQuery = trpc.logiciels.byEtablissement.useQuery({ etablissementId: id }, { enabled: !!id });
|
||||
const recordConsultation = trpc.tracabilite.enregistrerConsultation.useMutation();
|
||||
|
||||
const isGestionnaire = user?.sonumRole === "gestionnaire" || user?.role === "admin";
|
||||
const isReferent = etabQuery.data?.referentId === user?.id;
|
||||
const canSeeCounter = isReferent || isGestionnaire;
|
||||
|
||||
const compteurQuery = trpc.tracabilite.compteur.useQuery(
|
||||
{ etablissementId: id },
|
||||
{ enabled: !!id && canSeeCounter }
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
recordConsultation.mutate({ etablissementId: id });
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
if (etabQuery.isLoading) {
|
||||
return (
|
||||
<SonumLayout>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
</SonumLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!etabQuery.data) {
|
||||
return (
|
||||
<SonumLayout>
|
||||
<div className="p-8 text-center text-muted-foreground">Établissement introuvable.</div>
|
||||
</SonumLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const etab = etabQuery.data;
|
||||
|
||||
// Grouper les logiciels par bloc fonctionnel
|
||||
const logicielsByBloc = (logicielsQuery.data ?? []).reduce((acc, l) => {
|
||||
const bloc = l.blocFonctionnelNom ?? "Autres";
|
||||
if (!acc[bloc]) acc[bloc] = [];
|
||||
acc[bloc].push(l);
|
||||
return acc;
|
||||
}, {} as Record<string, typeof logicielsQuery.data>);
|
||||
|
||||
return (
|
||||
<SonumLayout>
|
||||
<div className="p-6 lg:p-8 max-w-5xl mx-auto">
|
||||
{/* Retour */}
|
||||
<button
|
||||
onClick={() => navigate(-1 as any)}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors mb-6"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
Retour
|
||||
</button>
|
||||
|
||||
{/* En-tête fiche */}
|
||||
<div className="bg-card rounded-xl border border-border shadow-sm p-6 mb-6">
|
||||
<div className="flex items-start justify-between flex-wrap gap-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-14 h-14 rounded-xl bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<Building2 size={28} className="text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-foreground mb-1">{etab.nom}</h1>
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
|
||||
{etab.finess && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Tag size={13} />
|
||||
FINESS : {etab.finess}
|
||||
</span>
|
||||
)}
|
||||
{etab.region && (
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin size={13} />
|
||||
{etab.region}
|
||||
</span>
|
||||
)}
|
||||
{etab.typeActivite && (
|
||||
<span className="bg-secondary text-secondary-foreground px-2.5 py-0.5 rounded-full text-xs font-medium border border-border">
|
||||
{etab.typeActivite}
|
||||
</span>
|
||||
)}
|
||||
{etab.tailleEffectifs && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Users size={13} />
|
||||
{etab.tailleEffectifs}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Compteur de consultation (visible référent + gestionnaire) */}
|
||||
{canSeeCounter && compteurQuery.data !== undefined && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-muted rounded-lg border border-border text-sm">
|
||||
<Eye size={15} className="text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Consultations :</span>
|
||||
<span className="font-bold text-foreground">{compteurQuery.data.count}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bouton de contact */}
|
||||
{etab.accepteMiseEnRelation && (
|
||||
<button
|
||||
onClick={() => setShowContact(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary/90 transition-colors shadow-sm"
|
||||
>
|
||||
<Mail size={15} />
|
||||
Prendre contact
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logiciels par bloc fonctionnel */}
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-foreground mb-4">Solutions numériques</h2>
|
||||
|
||||
{logicielsQuery.isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-7 w-7 border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
) : Object.keys(logicielsByBloc).length === 0 ? (
|
||||
<div className="text-center py-12 bg-card rounded-xl border border-border">
|
||||
<Server size={40} className="mx-auto text-muted-foreground/30 mb-3" />
|
||||
<p className="text-muted-foreground">Aucun logiciel renseigné pour cet établissement.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{Object.entries(logicielsByBloc).map(([bloc, logiciels]) => (
|
||||
<div key={bloc} className="bg-card rounded-xl border border-border shadow-sm overflow-hidden">
|
||||
{/* En-tête bloc */}
|
||||
<div className="px-5 py-3.5 bg-muted/30 border-b border-border">
|
||||
<h3 className="text-sm font-semibold text-foreground flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-primary inline-block" />
|
||||
{bloc}
|
||||
<span className="text-xs text-muted-foreground font-normal ml-1">
|
||||
({logiciels?.length} solution{(logiciels?.length ?? 0) > 1 ? "s" : ""})
|
||||
</span>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Tableau logiciels */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="text-left px-5 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider">Solution</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider hidden md:table-cell">Éditeur</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider">État</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider hidden lg:table-cell">Hébergement</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider hidden xl:table-cell">Facturation</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider hidden xl:table-cell">Interop.</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{logiciels?.map((l) => (
|
||||
<tr key={l.id} className="hover:bg-muted/20 transition-colors">
|
||||
<td className="px-5 py-3.5">
|
||||
<div className="font-medium text-foreground">{l.solutionNom}</div>
|
||||
{l.versionMajeure && (
|
||||
<div className="text-xs text-muted-foreground">v{l.versionMajeure}</div>
|
||||
)}
|
||||
{l.commentaire && (
|
||||
<div className="text-xs text-muted-foreground mt-0.5 italic">{l.commentaire}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3.5 hidden md:table-cell text-muted-foreground">{l.editeurNom}</td>
|
||||
<td className="px-4 py-3.5">
|
||||
<EtatBadge etat={l.etatDeploiement} />
|
||||
</td>
|
||||
<td className="px-4 py-3.5 hidden lg:table-cell">
|
||||
{l.modeHebergement ? <HebergementBadge mode={l.modeHebergement} /> : <span className="text-muted-foreground text-xs">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3.5 hidden xl:table-cell">
|
||||
{l.modeFacturation ? <FacturationBadge mode={l.modeFacturation} /> : <span className="text-muted-foreground text-xs">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3.5 hidden xl:table-cell">
|
||||
{l.interoperabilite ? (
|
||||
<span className="text-xs text-muted-foreground">{INTEROPERABILITE[l.interoperabilite]}</span>
|
||||
) : <span className="text-muted-foreground text-xs">—</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showContact && (
|
||||
<ContactModal
|
||||
etablissementId={id}
|
||||
etablissementNom={etab.nom}
|
||||
onClose={() => setShowContact(false)}
|
||||
/>
|
||||
)}
|
||||
</SonumLayout>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
248
client/src/pages/MesDemandes.tsx
Normal file
248
client/src/pages/MesDemandes.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
369
client/src/pages/MesEtablissements.tsx
Normal file
369
client/src/pages/MesEtablissements.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
13
drizzle/0000_complete_luminals.sql
Normal file
13
drizzle/0000_complete_luminals.sql
Normal 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`)
|
||||
);
|
||||
89
drizzle/0001_wide_frightful_four.sql
Normal file
89
drizzle/0001_wide_frightful_four.sql
Normal 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;
|
||||
110
drizzle/meta/0000_snapshot.json
Normal file
110
drizzle/meta/0000_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
677
drizzle/meta/0001_snapshot.json
Normal file
677
drizzle/meta/0001_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
71
scripts/seed.mjs
Normal 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);
|
||||
424
server/db.ts
424
server/db.ts
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
154
server/sonum.test.ts
Normal 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
94
shared/referentiel.ts
Normal 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
33
todo.md
Normal 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)
|
||||
Reference in New Issue
Block a user