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:
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
279
client/src/pages/MesEchanges.tsx
Normal file
279
client/src/pages/MesEchanges.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
417
client/src/pages/MiseEnRelation.tsx
Normal file
417
client/src/pages/MiseEnRelation.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user