Checkpoint: Implémentation des fonctionnalités de communication : formulaire de contact avec choix email/canal, canaux de discussion intégrés (page Mes Échanges), demandes de mise en relation avec gestion admin des canaux, navigation mise à jour dans DashboardLayout, bouton Demande de mise en relation sur fiche établissement. Migration DB : tables canaux_discussion, membres_canal, messages_canal, demandes_mise_en_relation.

This commit is contained in:
Manus
2026-05-13 10:40:13 -04:00
parent 46321a1eaf
commit 0205859880
13 changed files with 2437 additions and 57 deletions

View File

@@ -14,6 +14,8 @@ import LoginLocal from "./pages/LoginLocal";
import MesSolutions from "./pages/MesSolutions";
import SolutionsLogicielles from "./pages/SolutionsLogicielles";
import Statistiques from "./pages/Statistiques";
import MesEchanges from "./pages/MesEchanges";
import MiseEnRelation from "./pages/MiseEnRelation";
function Router() {
return (
@@ -31,6 +33,8 @@ function Router() {
<Route path="/mes-solutions" component={MesSolutions} />
<Route path="/solutions" component={SolutionsLogicielles} />
<Route path="/statistiques" component={Statistiques} />
<Route path="/mes-echanges" component={MesEchanges} />
<Route path="/mise-en-relation" component={MiseEnRelation} />
{/* Fallback */}
<Route path="/404" component={NotFound} />

View File

@@ -1,5 +1,5 @@
import { trpc } from "@/lib/trpc";
import { Building2, Mail, Send, X } from "lucide-react";
import { Building2, Mail, MessageSquare, Send, X } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
@@ -9,87 +9,158 @@ interface ContactModalProps {
onClose: () => void;
}
type Mode = "choix" | "email" | "canal";
export default function ContactModal({ etablissementId, etablissementNom, onClose }: ContactModalProps) {
const [mode, setMode] = useState<Mode>("choix");
const [message, setMessage] = useState("");
const sendMutation = trpc.contact.envoyer.useMutation({
const sendEmailMutation = trpc.contact.envoyer.useMutation({
onSuccess: () => {
toast.success("Votre demande de contact a été envoyée avec succès.");
toast.success("Votre demande a été transmise au référent numérique par email.");
onClose();
},
onError: (err) => {
toast.error("Erreur lors de l'envoi : " + err.message);
},
onError: (err) => toast.error("Erreur lors de l'envoi : " + err.message),
});
const handleSubmit = (e: React.FormEvent) => {
const createCanalMutation = trpc.canaux.create.useMutation({
onSuccess: () => {
toast.success("Un canal de discussion a été ouvert. Retrouvez-le dans « Mes Échanges ».");
onClose();
},
onError: (err) => toast.error("Erreur : " + err.message),
});
const etabQuery = trpc.etablissements.byId.useQuery({ id: etablissementId });
const referentId = etabQuery.data?.referentId;
const handleSubmitEmail = (e: React.FormEvent) => {
e.preventDefault();
if (!message.trim()) return;
sendMutation.mutate({ etablissementCibleId: etablissementId, message });
sendEmailMutation.mutate({ etablissementCibleId: etablissementId, message });
};
const handleSubmitCanal = (e: React.FormEvent) => {
e.preventDefault();
if (!message.trim()) return;
const membreIds = referentId ? [referentId] : [];
createCanalMutation.mutate({
titre: `Contact — ${etablissementNom}`,
description: message,
type: "contact_referent",
visibilite: "prive",
etablissementId,
membreIds,
});
};
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>
<h2 className="text-base font-bold text-foreground">Contacter le référent numérique</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"
>
<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="p-6">
{mode === "choix" && (
<div className="space-y-3">
<p className="text-sm text-muted-foreground mb-4">
Comment souhaitez-vous entrer en contact avec le référent numérique de cet établissement ?
</p>
<button
onClick={() => setMode("email")}
className="w-full flex items-start gap-4 p-4 rounded-xl border border-border hover:border-primary hover:bg-primary/5 transition-all text-left group"
>
<div className="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center shrink-0 group-hover:bg-blue-500/20 transition-colors">
<Mail size={20} className="text-blue-600" />
</div>
<div>
<p className="text-sm font-semibold text-foreground">Par email (hors plateforme)</p>
<p className="text-xs text-muted-foreground mt-0.5">
Le référent reçoit un email avec vos coordonnées. Les échanges se poursuivent par email.
</p>
</div>
</button>
<button
onClick={() => setMode("canal")}
className="w-full flex items-start gap-4 p-4 rounded-xl border border-border hover:border-primary hover:bg-primary/5 transition-all text-left group"
>
<div className="w-10 h-10 rounded-lg bg-green-500/10 flex items-center justify-center shrink-0 group-hover:bg-green-500/20 transition-colors">
<MessageSquare size={20} className="text-green-600" />
</div>
<div>
<p className="text-sm font-semibold text-foreground">Canal de discussion SONUM</p>
<p className="text-xs text-muted-foreground mt-0.5">
Un espace de discussion privé est ouvert dans SONUM. Le référent est notifié et peut répondre directement sur la plateforme.
</p>
</div>
</button>
</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>
{(mode === "email" || mode === "canal") && (
<form onSubmit={mode === "email" ? handleSubmitEmail : handleSubmitCanal}>
<div className={`flex items-center gap-2 px-3 py-2 rounded-lg mb-4 text-xs font-medium ${
mode === "email" ? "bg-blue-500/10 text-blue-700" : "bg-green-500/10 text-green-700"
}`}>
{mode === "email" ? <Mail size={14} /> : <MessageSquare size={14} />}
{mode === "email" ? "Envoi par email" : "Canal de discussion SONUM"}
<button type="button" onClick={() => setMode("choix")} className="ml-auto text-muted-foreground hover:text-foreground underline text-xs">
Changer
</button>
</div>
<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={
mode === "email"
? "Décrivez votre besoin ou votre question. Vos coordonnées seront transmises au référent."
: "Décrivez votre besoin. Ce message sera le premier message du canal de discussion."
}
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">
{mode === "email"
? "Le référent numérique et les gestionnaires SONUM recevront votre message par email."
: "Un canal de discussion privé sera créé. Le référent et les gestionnaires SONUM y auront accès."}
</p>
</div>
<div className="flex gap-3">
<button type="button" onClick={() => setMode("choix")} className="flex-1 py-2.5 px-4 rounded-lg text-sm font-medium border border-border text-foreground hover:bg-muted transition-colors">
Retour
</button>
<button
type="submit"
disabled={!message.trim() || sendEmailMutation.isPending || createCanalMutation.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} />
{sendEmailMutation.isPending || createCanalMutation.isPending ? "Envoi..." : "Envoyer"}
</button>
</div>
</form>
)}
</div>
</div>
</div>
);

View File

@@ -21,15 +21,21 @@ import {
} from "@/components/ui/sidebar";
import { getLoginUrl } from "@/const";
import { useIsMobile } from "@/hooks/useMobile";
import { LayoutDashboard, LogOut, PanelLeft, Users } from "lucide-react";
import { LayoutDashboard, LogOut, PanelLeft, Users, Building2, BookOpen, BarChart3, MessageSquare, UserPlus, Settings, FileText } from "lucide-react";
import { CSSProperties, useEffect, useRef, useState } from "react";
import { useLocation } from "wouter";
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
import { Button } from "./ui/button";
const menuItems = [
{ icon: LayoutDashboard, label: "Page 1", path: "/" },
{ icon: Users, label: "Page 2", path: "/some-path" },
{ icon: LayoutDashboard, label: "Accueil", path: "/", adminOnly: false },
{ icon: Building2, label: "Mes Établissements", path: "/mes-etablissements", adminOnly: false },
{ icon: BookOpen, label: "Solutions Logicielles", path: "/solutions", adminOnly: false },
{ icon: FileText, label: "Mes Solutions", path: "/mes-solutions", adminOnly: false },
{ icon: MessageSquare, label: "Mes Échanges", path: "/mes-echanges", adminOnly: false },
{ icon: UserPlus, label: "Mise en relation", path: "/mise-en-relation", adminOnly: false },
{ icon: BarChart3, label: "Statistiques", path: "/statistiques", adminOnly: false },
{ icon: Settings, label: "Administration", path: "/admin", adminOnly: true },
];
const SIDEBAR_WIDTH_KEY = "sidebar-width";
@@ -112,7 +118,9 @@ function DashboardLayoutContent({
const isCollapsed = state === "collapsed";
const [isResizing, setIsResizing] = useState(false);
const sidebarRef = useRef<HTMLDivElement>(null);
const activeMenuItem = menuItems.find(item => item.path === location);
const isAdmin = user?.role === "admin" || user?.sonumRole === "gestionnaire";
const visibleMenuItems = menuItems.filter(item => !item.adminOnly || isAdmin);
const activeMenuItem = visibleMenuItems.find(item => item.path === location);
const isMobile = useIsMobile();
useEffect(() => {
@@ -180,7 +188,7 @@ function DashboardLayoutContent({
<SidebarContent className="gap-0">
<SidebarMenu className="px-2 py-1">
{menuItems.map(item => {
{visibleMenuItems.map(item => {
const isActive = location === item.path;
return (
<SidebarMenuItem key={item.path}>

View File

@@ -13,6 +13,7 @@ import {
Server,
Tag,
Users,
UserPlus,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useLocation } from "wouter";
@@ -144,6 +145,14 @@ export default function FicheEtablissement({ params }: Props) {
Prendre contact
</button>
)}
{/* Bouton demande de mise en relation */}
<button
onClick={() => navigate("/mise-en-relation")}
className="flex items-center gap-2 px-4 py-2 bg-muted border border-border text-foreground rounded-lg text-sm font-medium hover:bg-muted/80 transition-colors"
>
<UserPlus size={15} />
Demande de mise en relation
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,279 @@
import { trpc } from "@/lib/trpc";
import { useAuth } from "@/_core/hooks/useAuth";
import { useState, useRef, useEffect } from "react";
import { Link } from "wouter";
import {
MessageSquare, Send, ArrowLeft, Users, Lock, Globe,
Building2, Clock, ChevronRight, Inbox
} from "lucide-react";
// ─── Types ────────────────────────────────────────────────────────────────────
type Canal = {
id: number;
titre: string;
description?: string | null;
type: string;
visibilite: string;
etablissementId?: number | null;
etablissementNom?: string | null;
createdAt: Date;
nbMessages?: number;
};
type Message = {
id: number;
auteurId: number;
auteurNom: string;
contenu: string;
createdAt: Date;
};
// ─── Composant principal ──────────────────────────────────────────────────────
export default function MesEchanges() {
const { user } = useAuth();
const [selectedCanalId, setSelectedCanalId] = useState<number | null>(null);
const [newMessage, setNewMessage] = useState("");
const messagesEndRef = useRef<HTMLDivElement>(null);
const canauxQuery = trpc.canaux.list.useQuery();
const messagesQuery = trpc.canaux.messages.useQuery(
{ canalId: selectedCanalId! },
{ enabled: !!selectedCanalId, refetchInterval: 5000 }
);
const utils = trpc.useUtils();
const sendMutation = trpc.canaux.sendMessage.useMutation({
onSuccess: () => {
setNewMessage("");
utils.canaux.messages.invalidate({ canalId: selectedCanalId! });
},
});
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messagesQuery.data]);
const selectedCanal = canauxQuery.data?.find((c: Canal) => c.id === selectedCanalId);
const handleSend = (e: React.FormEvent) => {
e.preventDefault();
if (!newMessage.trim() || !selectedCanalId) return;
sendMutation.mutate({ canalId: selectedCanalId, contenu: newMessage });
};
const typeLabel = (type: string) => {
switch (type) {
case "contact_referent": return "Contact référent";
case "mise_en_relation_public": return "Mise en relation publique";
case "mise_en_relation_prive": return "Mise en relation privée";
default: return type;
}
};
const typeColor = (type: string) => {
switch (type) {
case "contact_referent": return "bg-blue-500/10 text-blue-700";
case "mise_en_relation_public": return "bg-green-500/10 text-green-700";
case "mise_en_relation_prive": return "bg-purple-500/10 text-purple-700";
default: return "bg-muted text-muted-foreground";
}
};
return (
<div className="min-h-screen bg-background">
{/* Header */}
<div className="border-b border-border bg-card/50 backdrop-blur-sm sticky top-0 z-10">
<div className="max-w-7xl mx-auto px-4 py-4 flex items-center gap-4">
<Link href="/" className="p-2 rounded-lg hover:bg-muted transition-colors text-muted-foreground hover:text-foreground">
<ArrowLeft size={18} />
</Link>
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-primary/10 flex items-center justify-center">
<MessageSquare size={18} className="text-primary" />
</div>
<div>
<h1 className="text-base font-bold text-foreground">Mes Échanges</h1>
<p className="text-xs text-muted-foreground">Canaux de discussion SONUM</p>
</div>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto px-4 py-6">
<div className="flex gap-6 h-[calc(100vh-140px)]">
{/* Liste des canaux */}
<div className="w-80 shrink-0 flex flex-col gap-3">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider px-1">
{canauxQuery.data?.length ?? 0} canal{(canauxQuery.data?.length ?? 0) > 1 ? "x" : ""}
</p>
<div className="flex flex-col gap-2 overflow-y-auto">
{canauxQuery.isLoading && (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<div key={i} className="h-20 rounded-xl bg-muted animate-pulse" />
))}
</div>
)}
{!canauxQuery.isLoading && (!canauxQuery.data || canauxQuery.data.length === 0) && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="w-12 h-12 rounded-2xl bg-muted flex items-center justify-center mb-3">
<Inbox size={22} className="text-muted-foreground" />
</div>
<p className="text-sm font-medium text-foreground">Aucun échange</p>
<p className="text-xs text-muted-foreground mt-1">
Contactez un référent depuis la fiche d'un établissement pour démarrer un échange.
</p>
</div>
)}
{canauxQuery.data?.map((canal: Canal) => (
<button
key={canal.id}
onClick={() => setSelectedCanalId(canal.id)}
className={`w-full text-left p-4 rounded-xl border transition-all ${
selectedCanalId === canal.id
? "border-primary bg-primary/5 shadow-sm"
: "border-border hover:border-primary/50 hover:bg-muted/50"
}`}
>
<div className="flex items-start justify-between gap-2 mb-2">
<p className="text-sm font-semibold text-foreground line-clamp-1 flex-1">{canal.titre}</p>
<div className="flex items-center gap-1 shrink-0">
{canal.visibilite === "prive" ? (
<Lock size={12} className="text-muted-foreground" />
) : (
<Globe size={12} className="text-muted-foreground" />
)}
</div>
</div>
{canal.etablissementNom && (
<div className="flex items-center gap-1 mb-2">
<Building2 size={11} className="text-muted-foreground" />
<span className="text-xs text-muted-foreground">{canal.etablissementNom}</span>
</div>
)}
<div className="flex items-center justify-between">
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${typeColor(canal.type)}`}>
{typeLabel(canal.type)}
</span>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Clock size={10} />
<span>{new Date(canal.createdAt).toLocaleDateString("fr-FR")}</span>
</div>
</div>
</button>
))}
</div>
</div>
{/* Zone de messagerie */}
<div className="flex-1 flex flex-col border border-border rounded-2xl overflow-hidden bg-card">
{!selectedCanalId ? (
<div className="flex-1 flex flex-col items-center justify-center text-center p-8">
<div className="w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center mb-4">
<MessageSquare size={28} className="text-primary" />
</div>
<h3 className="text-base font-bold text-foreground mb-2">Sélectionnez un canal</h3>
<p className="text-sm text-muted-foreground max-w-xs">
Choisissez un canal dans la liste à gauche pour consulter et participer à la discussion.
</p>
</div>
) : (
<>
{/* En-tête du canal */}
<div className="px-6 py-4 border-b border-border flex items-center justify-between">
<div>
<h2 className="text-sm font-bold text-foreground">{selectedCanal?.titre}</h2>
{selectedCanal?.description && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-1">{selectedCanal.description}</p>
)}
</div>
<div className="flex items-center gap-2">
{selectedCanal && (
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${typeColor(selectedCanal.type)}`}>
{typeLabel(selectedCanal.type)}
</span>
)}
{selectedCanal?.visibilite === "prive" ? (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Lock size={12} />
<span>Privé</span>
</div>
) : (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Globe size={12} />
<span>Public</span>
</div>
)}
</div>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-6 space-y-4">
{messagesQuery.isLoading && (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className={`flex ${i % 2 === 0 ? "justify-end" : ""}`}>
<div className="h-14 w-64 rounded-2xl bg-muted animate-pulse" />
</div>
))}
</div>
)}
{!messagesQuery.isLoading && messagesQuery.data?.length === 0 && (
<div className="flex flex-col items-center justify-center h-full text-center">
<MessageSquare size={32} className="text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground">Aucun message pour l'instant. Soyez le premier à écrire !</p>
</div>
)}
{messagesQuery.data?.map((msg: Message) => {
const isMe = msg.auteurId === user?.id;
return (
<div key={msg.id} className={`flex ${isMe ? "justify-end" : "justify-start"}`}>
<div className={`max-w-[70%] ${isMe ? "items-end" : "items-start"} flex flex-col gap-1`}>
{!isMe && (
<span className="text-xs font-medium text-muted-foreground px-1">{msg.auteurNom}</span>
)}
<div className={`px-4 py-3 rounded-2xl text-sm ${
isMe
? "bg-primary text-white rounded-br-sm"
: "bg-muted text-foreground rounded-bl-sm"
}`}>
{msg.contenu}
</div>
<span className="text-xs text-muted-foreground px-1">
{new Date(msg.createdAt).toLocaleString("fr-FR", { hour: "2-digit", minute: "2-digit", day: "2-digit", month: "short" })}
</span>
</div>
</div>
);
})}
<div ref={messagesEndRef} />
</div>
{/* Zone de saisie */}
<div className="px-6 py-4 border-t border-border">
<form onSubmit={handleSend} className="flex gap-3">
<input
type="text"
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
placeholder="Écrivez votre message..."
className="flex-1 px-4 py-2.5 text-sm bg-background border border-border rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-all"
/>
<button
type="submit"
disabled={!newMessage.trim() || sendMutation.isPending}
className="px-4 py-2.5 rounded-xl bg-primary text-white hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 text-sm font-medium"
>
<Send size={15} />
Envoyer
</button>
</form>
</div>
</>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,417 @@
import { trpc } from "@/lib/trpc";
import { useAuth } from "@/_core/hooks/useAuth";
import { useState } from "react";
import { Link } from "wouter";
import {
ArrowLeft, Users, Send, Plus, CheckCircle, Clock,
MessageSquare, Building2, Globe, Lock, ChevronDown, ChevronUp, X
} from "lucide-react";
import { toast } from "sonner";
// ─── Types ────────────────────────────────────────────────────────────────────
type Demande = {
id: number;
demandeurId: number;
demandeurNom: string;
demandeurEmail?: string | null;
etablissementDemandeurNom?: string | null;
sujet: string;
message: string;
statut: string;
createdAt: Date;
canalId?: number | null;
};
type UserItem = {
id: number;
name: string | null;
firstName?: string | null;
lastName?: string | null;
email?: string | null;
};
// ─── Modal de création de canal ───────────────────────────────────────────────
function CreateCanalModal({
demande,
onClose,
onSuccess,
}: {
demande: Demande;
onClose: () => void;
onSuccess: () => void;
}) {
const [titre, setTitre] = useState(`Mise en relation — ${demande.sujet}`);
const [visibilite, setVisibilite] = useState<"public" | "prive">("prive");
const [type, setType] = useState<"mise_en_relation_public" | "mise_en_relation_prive">("mise_en_relation_prive");
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([demande.demandeurId]);
const usersQuery = trpc.admin.users.useQuery();
const utils = trpc.useUtils();
const createMutation = trpc.canaux.create.useMutation({
onSuccess: () => {
toast.success("Canal de discussion créé avec succès.");
utils.miseEnRelation.all.invalidate();
onSuccess();
onClose();
},
onError: (err) => toast.error("Erreur : " + err.message),
});
const toggleUser = (id: number) => {
setSelectedUserIds((prev) =>
prev.includes(id) ? prev.filter((u) => u !== id) : [...prev, id]
);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
createMutation.mutate({
titre,
description: demande.message,
type,
visibilite,
demandeMiseEnRelationId: demande.id,
membreIds: selectedUserIds,
});
};
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 max-h-[90vh] overflow-y-auto">
<div className="flex items-start justify-between px-6 py-5 border-b border-border sticky top-0 bg-card">
<div>
<h2 className="text-base font-bold text-foreground">Créer un canal de discussion</h2>
<p className="text-xs text-muted-foreground mt-0.5">Pour la demande : {demande.sujet}</p>
</div>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-muted transition-colors text-muted-foreground">
<X size={18} />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-5">
{/* Titre */}
<div>
<label className="block text-sm font-medium text-foreground mb-1.5">Titre du canal</label>
<input
type="text"
value={titre}
onChange={(e) => setTitre(e.target.value)}
className="w-full px-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"
required
/>
</div>
{/* Type et visibilité */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-foreground mb-1.5">Type</label>
<select
value={type}
onChange={(e) => {
const v = e.target.value as typeof type;
setType(v);
setVisibilite(v === "mise_en_relation_public" ? "public" : "prive");
}}
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"
>
<option value="mise_en_relation_prive">Groupe privé</option>
<option value="mise_en_relation_public">Canal public</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1.5">Visibilité</label>
<div className={`flex items-center gap-2 px-3 py-2.5 rounded-lg text-sm border ${
visibilite === "public" ? "bg-green-500/10 border-green-500/30 text-green-700" : "bg-purple-500/10 border-purple-500/30 text-purple-700"
}`}>
{visibilite === "public" ? <Globe size={14} /> : <Lock size={14} />}
{visibilite === "public" ? "Public" : "Privé"}
</div>
</div>
</div>
{/* Membres */}
<div>
<label className="block text-sm font-medium text-foreground mb-1.5">
Membres invités ({selectedUserIds.length} sélectionné{selectedUserIds.length > 1 ? "s" : ""})
</label>
<div className="border border-border rounded-lg overflow-hidden max-h-48 overflow-y-auto">
{usersQuery.isLoading ? (
<div className="p-4 text-sm text-muted-foreground text-center">Chargement...</div>
) : (
usersQuery.data?.map((u: UserItem) => (
<label
key={u.id}
className="flex items-center gap-3 px-4 py-2.5 hover:bg-muted cursor-pointer border-b border-border last:border-0"
>
<input
type="checkbox"
checked={selectedUserIds.includes(u.id)}
onChange={() => toggleUser(u.id)}
className="rounded"
/>
<div>
<p className="text-sm font-medium text-foreground">{u.name || `${u.firstName} ${u.lastName}`}</p>
{u.email && <p className="text-xs text-muted-foreground">{u.email}</p>}
</div>
{u.id === demande.demandeurId && (
<span className="ml-auto text-xs bg-blue-500/10 text-blue-700 px-2 py-0.5 rounded-full">Demandeur</span>
)}
</label>
))
)}
</div>
</div>
<div className="flex gap-3 pt-2">
<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={createMutation.isPending || !titre.trim()}
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"
>
<Plus size={15} />
{createMutation.isPending ? "Création..." : "Créer le canal"}
</button>
</div>
</form>
</div>
</div>
);
}
// ─── Composant principal ──────────────────────────────────────────────────────
export default function MiseEnRelation() {
const { user } = useAuth();
const isGestionnaire = user?.sonumRole === "gestionnaire" || user?.role === "admin";
const [showForm, setShowForm] = useState(false);
const [sujet, setSujet] = useState("");
const [message, setMessage] = useState("");
const [expandedId, setExpandedId] = useState<number | null>(null);
const [createCanalFor, setCreateCanalFor] = useState<Demande | null>(null);
const utils = trpc.useUtils();
const mesDemandesQuery = trpc.miseEnRelation.mesDemandes.useQuery();
const allDemandesQuery = trpc.miseEnRelation.all.useQuery(undefined, { enabled: isGestionnaire });
const soumettresMutation = trpc.miseEnRelation.soumettre.useMutation({
onSuccess: () => {
toast.success("Votre demande de mise en relation a été transmise aux gestionnaires SONUM.");
setSujet("");
setMessage("");
setShowForm(false);
utils.miseEnRelation.mesDemandes.invalidate();
},
onError: (err) => toast.error("Erreur : " + err.message),
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!sujet.trim() || !message.trim()) return;
soumettresMutation.mutate({ sujet, message });
};
const statutBadge = (statut: string) => {
switch (statut) {
case "en_attente": return <span className="text-xs px-2 py-0.5 rounded-full bg-amber-500/10 text-amber-700 font-medium">En attente</span>;
case "traite": return <span className="text-xs px-2 py-0.5 rounded-full bg-green-500/10 text-green-700 font-medium">Traité</span>;
case "clos": return <span className="text-xs px-2 py-0.5 rounded-full bg-muted text-muted-foreground font-medium">Clos</span>;
default: return null;
}
};
const demandes: Demande[] = isGestionnaire ? (allDemandesQuery.data ?? []) : (mesDemandesQuery.data ?? []);
return (
<div className="min-h-screen bg-background">
{/* Header */}
<div className="border-b border-border bg-card/50 backdrop-blur-sm sticky top-0 z-10">
<div className="max-w-5xl mx-auto px-4 py-4 flex items-center gap-4">
<Link href="/" className="p-2 rounded-lg hover:bg-muted transition-colors text-muted-foreground hover:text-foreground">
<ArrowLeft size={18} />
</Link>
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-primary/10 flex items-center justify-center">
<Users size={18} className="text-primary" />
</div>
<div>
<h1 className="text-base font-bold text-foreground">Mise en relation</h1>
<p className="text-xs text-muted-foreground">
{isGestionnaire ? "Gestion des demandes de mise en relation" : "Demandez à être mis en relation avec d'autres établissements"}
</p>
</div>
</div>
{!isGestionnaire && (
<button
onClick={() => setShowForm(!showForm)}
className="ml-auto flex items-center gap-2 px-4 py-2 rounded-xl bg-primary text-white text-sm font-medium hover:bg-primary/90 transition-colors"
>
<Plus size={15} />
Nouvelle demande
</button>
)}
</div>
</div>
<div className="max-w-5xl mx-auto px-4 py-6 space-y-6">
{/* Formulaire de demande (utilisateurs) */}
{!isGestionnaire && showForm && (
<div className="bg-card border border-border rounded-2xl p-6">
<h2 className="text-sm font-bold text-foreground mb-4">Nouvelle demande de mise en relation</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1.5">Sujet <span className="text-destructive">*</span></label>
<input
type="text"
value={sujet}
onChange={(e) => setSujet(e.target.value)}
placeholder="Ex: Partage d'expérience sur la mise en place d'un DPI"
className="w-full px-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"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1.5">Détail de votre besoin <span className="text-destructive">*</span></label>
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Décrivez votre besoin en détail pour que les gestionnaires SONUM puissent identifier les établissements pertinents..."
rows={4}
className="w-full px-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 resize-none"
required
/>
<p className="text-xs text-muted-foreground mt-1">
Les gestionnaires SONUM recevront votre demande et créeront un espace d'échange avec les établissements concernés.
</p>
</div>
<div className="flex gap-3">
<button type="button" onClick={() => setShowForm(false)} 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={!sujet.trim() || !message.trim() || soumettresMutation.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"
>
<Send size={15} />
{soumettresMutation.isPending ? "Envoi..." : "Envoyer la demande"}
</button>
</div>
</form>
</div>
)}
{/* Liste des demandes */}
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-bold text-foreground">
{isGestionnaire ? "Toutes les demandes" : "Mes demandes"}
<span className="ml-2 text-xs font-normal text-muted-foreground">({demandes.length})</span>
</h2>
</div>
{demandes.length === 0 && (
<div className="flex flex-col items-center justify-center py-16 text-center border border-dashed border-border rounded-2xl">
<div className="w-12 h-12 rounded-2xl bg-muted flex items-center justify-center mb-3">
<Users size={22} className="text-muted-foreground" />
</div>
<p className="text-sm font-medium text-foreground">Aucune demande</p>
<p className="text-xs text-muted-foreground mt-1">
{isGestionnaire ? "Aucune demande de mise en relation reçue." : "Vous n'avez pas encore soumis de demande."}
</p>
</div>
)}
<div className="space-y-3">
{demandes.map((demande: Demande) => (
<div key={demande.id} className="bg-card border border-border rounded-xl overflow-hidden">
{/* En-tête de la demande */}
<button
onClick={() => setExpandedId(expandedId === demande.id ? null : demande.id)}
className="w-full flex items-center justify-between px-5 py-4 hover:bg-muted/50 transition-colors text-left"
>
<div className="flex items-start gap-3 flex-1 min-w-0">
<div className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center shrink-0 mt-0.5">
<Users size={15} className="text-primary" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-foreground truncate">{demande.sujet}</p>
<div className="flex items-center gap-3 mt-1">
{isGestionnaire && (
<span className="text-xs text-muted-foreground">
De : <span className="font-medium">{demande.demandeurNom}</span>
{demande.etablissementDemandeurNom && ` — ${demande.etablissementDemandeurNom}`}
</span>
)}
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Clock size={10} />
{new Date(demande.createdAt).toLocaleDateString("fr-FR")}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-3 shrink-0 ml-3">
{statutBadge(demande.statut)}
{expandedId === demande.id ? <ChevronUp size={16} className="text-muted-foreground" /> : <ChevronDown size={16} className="text-muted-foreground" />}
</div>
</button>
{/* Détail de la demande */}
{expandedId === demande.id && (
<div className="px-5 pb-5 border-t border-border pt-4">
<p className="text-sm text-foreground whitespace-pre-wrap">{demande.message}</p>
{/* Actions gestionnaire */}
{isGestionnaire && demande.statut === "en_attente" && (
<div className="mt-4 pt-4 border-t border-border">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Actions</p>
<div className="flex gap-3">
<button
onClick={() => setCreateCanalFor(demande)}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium bg-primary text-white hover:bg-primary/90 transition-colors"
>
<MessageSquare size={14} />
Créer un canal de discussion
</button>
</div>
<p className="text-xs text-muted-foreground mt-2">
Créez un canal public ou privé pour mettre en relation les établissements concernés.
</p>
</div>
)}
{/* Lien vers le canal si traité */}
{demande.canalId && (
<div className="mt-4 pt-4 border-t border-border">
<Link
href="/mes-echanges"
className="flex items-center gap-2 text-sm text-primary hover:underline font-medium"
>
<MessageSquare size={14} />
Accéder au canal de discussion
</Link>
</div>
)}
</div>
)}
</div>
))}
</div>
</div>
</div>
{/* Modal création canal */}
{createCanalFor && (
<CreateCanalModal
demande={createCanalFor}
onClose={() => setCreateCanalFor(null)}
onSuccess={() => utils.miseEnRelation.all.invalidate()}
/>
)}
</div>
);
}