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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user