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 MesSolutions from "./pages/MesSolutions";
|
||||||
import SolutionsLogicielles from "./pages/SolutionsLogicielles";
|
import SolutionsLogicielles from "./pages/SolutionsLogicielles";
|
||||||
import Statistiques from "./pages/Statistiques";
|
import Statistiques from "./pages/Statistiques";
|
||||||
|
import MesEchanges from "./pages/MesEchanges";
|
||||||
|
import MiseEnRelation from "./pages/MiseEnRelation";
|
||||||
|
|
||||||
function Router() {
|
function Router() {
|
||||||
return (
|
return (
|
||||||
@@ -31,6 +33,8 @@ function Router() {
|
|||||||
<Route path="/mes-solutions" component={MesSolutions} />
|
<Route path="/mes-solutions" component={MesSolutions} />
|
||||||
<Route path="/solutions" component={SolutionsLogicielles} />
|
<Route path="/solutions" component={SolutionsLogicielles} />
|
||||||
<Route path="/statistiques" component={Statistiques} />
|
<Route path="/statistiques" component={Statistiques} />
|
||||||
|
<Route path="/mes-echanges" component={MesEchanges} />
|
||||||
|
<Route path="/mise-en-relation" component={MiseEnRelation} />
|
||||||
|
|
||||||
{/* Fallback */}
|
{/* Fallback */}
|
||||||
<Route path="/404" component={NotFound} />
|
<Route path="/404" component={NotFound} />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { trpc } from "@/lib/trpc";
|
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 { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -9,52 +9,120 @@ interface ContactModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Mode = "choix" | "email" | "canal";
|
||||||
|
|
||||||
export default function ContactModal({ etablissementId, etablissementNom, onClose }: ContactModalProps) {
|
export default function ContactModal({ etablissementId, etablissementNom, onClose }: ContactModalProps) {
|
||||||
|
const [mode, setMode] = useState<Mode>("choix");
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
|
|
||||||
const sendMutation = trpc.contact.envoyer.useMutation({
|
const sendEmailMutation = trpc.contact.envoyer.useMutation({
|
||||||
onSuccess: () => {
|
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();
|
onClose();
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => toast.error("Erreur lors de l'envoi : " + err.message),
|
||||||
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();
|
e.preventDefault();
|
||||||
if (!message.trim()) return;
|
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 (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
<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">
|
<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-start justify-between px-6 py-5 border-b border-border">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center">
|
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center">
|
||||||
<Mail size={20} className="text-primary" />
|
<Mail size={20} className="text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<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">
|
<div className="flex items-center gap-1.5 text-sm text-muted-foreground mt-0.5">
|
||||||
<Building2 size={13} />
|
<Building2 size={13} />
|
||||||
<span>{etablissementNom}</span>
|
<span>{etablissementNom}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-muted transition-colors text-muted-foreground hover:text-foreground">
|
||||||
onClick={onClose}
|
|
||||||
className="p-1.5 rounded-lg hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<X size={18} />
|
<X size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Formulaire */}
|
<div className="p-6">
|
||||||
<form onSubmit={handleSubmit} 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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(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">
|
<div className="mb-5">
|
||||||
<label className="block text-sm font-medium text-foreground mb-2">
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
Votre message <span className="text-destructive">*</span>
|
Votre message <span className="text-destructive">*</span>
|
||||||
@@ -62,34 +130,37 @@ export default function ContactModal({ etablissementId, etablissementNom, onClos
|
|||||||
<textarea
|
<textarea
|
||||||
value={message}
|
value={message}
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
placeholder="Décrivez votre besoin ou votre question au référent numérique de cet établissement..."
|
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}
|
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"
|
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
|
required
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground mt-1.5">
|
<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.
|
{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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button
|
<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">
|
||||||
type="button"
|
Retour
|
||||||
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>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!message.trim() || sendMutation.isPending}
|
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"
|
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} />
|
<Send size={15} />
|
||||||
{sendMutation.isPending ? "Envoi..." : "Envoyer"}
|
{sendEmailMutation.isPending || createCanalMutation.isPending ? "Envoi..." : "Envoyer"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -21,15 +21,21 @@ import {
|
|||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import { getLoginUrl } from "@/const";
|
import { getLoginUrl } from "@/const";
|
||||||
import { useIsMobile } from "@/hooks/useMobile";
|
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 { CSSProperties, useEffect, useRef, useState } from "react";
|
||||||
import { useLocation } from "wouter";
|
import { useLocation } from "wouter";
|
||||||
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
|
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ icon: LayoutDashboard, label: "Page 1", path: "/" },
|
{ icon: LayoutDashboard, label: "Accueil", path: "/", adminOnly: false },
|
||||||
{ icon: Users, label: "Page 2", path: "/some-path" },
|
{ 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";
|
const SIDEBAR_WIDTH_KEY = "sidebar-width";
|
||||||
@@ -112,7 +118,9 @@ function DashboardLayoutContent({
|
|||||||
const isCollapsed = state === "collapsed";
|
const isCollapsed = state === "collapsed";
|
||||||
const [isResizing, setIsResizing] = useState(false);
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
const sidebarRef = useRef<HTMLDivElement>(null);
|
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();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -180,7 +188,7 @@ function DashboardLayoutContent({
|
|||||||
|
|
||||||
<SidebarContent className="gap-0">
|
<SidebarContent className="gap-0">
|
||||||
<SidebarMenu className="px-2 py-1">
|
<SidebarMenu className="px-2 py-1">
|
||||||
{menuItems.map(item => {
|
{visibleMenuItems.map(item => {
|
||||||
const isActive = location === item.path;
|
const isActive = location === item.path;
|
||||||
return (
|
return (
|
||||||
<SidebarMenuItem key={item.path}>
|
<SidebarMenuItem key={item.path}>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
Server,
|
Server,
|
||||||
Tag,
|
Tag,
|
||||||
Users,
|
Users,
|
||||||
|
UserPlus,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useLocation } from "wouter";
|
import { useLocation } from "wouter";
|
||||||
@@ -144,6 +145,14 @@ export default function FicheEtablissement({ params }: Props) {
|
|||||||
Prendre contact
|
Prendre contact
|
||||||
</button>
|
</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>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
drizzle/0004_empty_energizer.sql
Normal file
52
drizzle/0004_empty_energizer.sql
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
CREATE TABLE `canaux_discussion` (
|
||||||
|
`id` int AUTO_INCREMENT NOT NULL,
|
||||||
|
`titre` varchar(255) NOT NULL,
|
||||||
|
`description` text,
|
||||||
|
`type` enum('contact_referent','mise_en_relation_public','mise_en_relation_prive') NOT NULL,
|
||||||
|
`visibilite` enum('public','prive') NOT NULL DEFAULT 'prive',
|
||||||
|
`etablissementId` int,
|
||||||
|
`demandeContactId` int,
|
||||||
|
`demandeMiseEnRelationId` int,
|
||||||
|
`creePar` int NOT NULL,
|
||||||
|
`statut` enum('ouvert','ferme') NOT NULL DEFAULT 'ouvert',
|
||||||
|
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||||
|
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT `canaux_discussion_id` PRIMARY KEY(`id`)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `demandes_mise_en_relation` (
|
||||||
|
`id` int AUTO_INCREMENT NOT NULL,
|
||||||
|
`demandeurId` int NOT NULL,
|
||||||
|
`demandeurNom` varchar(255) NOT NULL,
|
||||||
|
`demandeurEmail` varchar(320),
|
||||||
|
`etablissementDemandeurId` int,
|
||||||
|
`sujet` varchar(500) NOT NULL,
|
||||||
|
`message` text NOT NULL,
|
||||||
|
`statut` enum('en_attente','traite','ferme') NOT NULL DEFAULT 'en_attente',
|
||||||
|
`canalId` int,
|
||||||
|
`traitePar` int,
|
||||||
|
`traiteAt` timestamp,
|
||||||
|
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||||
|
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT `demandes_mise_en_relation_id` PRIMARY KEY(`id`)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `membres_canaux_discussion` (
|
||||||
|
`id` int AUTO_INCREMENT NOT NULL,
|
||||||
|
`canalId` int NOT NULL,
|
||||||
|
`userId` int NOT NULL,
|
||||||
|
`role` enum('membre','animateur') NOT NULL DEFAULT 'membre',
|
||||||
|
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||||
|
CONSTRAINT `membres_canaux_discussion_id` PRIMARY KEY(`id`)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `messages_canaux` (
|
||||||
|
`id` int AUTO_INCREMENT NOT NULL,
|
||||||
|
`canalId` int NOT NULL,
|
||||||
|
`auteurId` int NOT NULL,
|
||||||
|
`auteurNom` varchar(255) NOT NULL,
|
||||||
|
`contenu` text NOT NULL,
|
||||||
|
`lu` boolean NOT NULL DEFAULT false,
|
||||||
|
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||||
|
CONSTRAINT `messages_canaux_id` PRIMARY KEY(`id`)
|
||||||
|
);
|
||||||
1160
drizzle/meta/0004_snapshot.json
Normal file
1160
drizzle/meta/0004_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,13 @@
|
|||||||
"when": 1776767429233,
|
"when": 1776767429233,
|
||||||
"tag": "0003_dry_dragon_man",
|
"tag": "0003_dry_dragon_man",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1778682305530,
|
||||||
|
"tag": "0004_empty_energizer",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -183,3 +183,60 @@ export const demandesContact = mysqlTable("demandes_contact", {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export type DemandeContact = typeof demandesContact.$inferSelect;
|
export type DemandeContact = typeof demandesContact.$inferSelect;
|
||||||
|
|
||||||
|
// ─── Canaux de Discussion ─────────────────────────────────────────────────────
|
||||||
|
export const canauxDiscussion = mysqlTable("canaux_discussion", {
|
||||||
|
id: int("id").autoincrement().primaryKey(),
|
||||||
|
titre: varchar("titre", { length: 255 }).notNull(),
|
||||||
|
description: text("description"),
|
||||||
|
type: mysqlEnum("type", ["contact_referent", "mise_en_relation_public", "mise_en_relation_prive"]).notNull(),
|
||||||
|
visibilite: mysqlEnum("visibilite", ["public", "prive"]).default("prive").notNull(),
|
||||||
|
etablissementId: int("etablissementId"),
|
||||||
|
demandeContactId: int("demandeContactId"),
|
||||||
|
demandeMiseEnRelationId: int("demandeMiseEnRelationId"),
|
||||||
|
creePar: int("creePar").notNull(),
|
||||||
|
statut: mysqlEnum("statut", ["ouvert", "ferme"]).default("ouvert").notNull(),
|
||||||
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||||
|
});
|
||||||
|
export type CanalDiscussion = typeof canauxDiscussion.$inferSelect;
|
||||||
|
|
||||||
|
// ─── Membres des Canaux ───────────────────────────────────────────────────────
|
||||||
|
export const membresCanauxDiscussion = mysqlTable("membres_canaux_discussion", {
|
||||||
|
id: int("id").autoincrement().primaryKey(),
|
||||||
|
canalId: int("canalId").notNull(),
|
||||||
|
userId: int("userId").notNull(),
|
||||||
|
role: mysqlEnum("role", ["membre", "animateur"]).default("membre").notNull(),
|
||||||
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
export type MembreCanalDiscussion = typeof membresCanauxDiscussion.$inferSelect;
|
||||||
|
|
||||||
|
// ─── Messages des Canaux ──────────────────────────────────────────────────────
|
||||||
|
export const messagesCanaux = mysqlTable("messages_canaux", {
|
||||||
|
id: int("id").autoincrement().primaryKey(),
|
||||||
|
canalId: int("canalId").notNull(),
|
||||||
|
auteurId: int("auteurId").notNull(),
|
||||||
|
auteurNom: varchar("auteurNom", { length: 255 }).notNull(),
|
||||||
|
contenu: text("contenu").notNull(),
|
||||||
|
lu: boolean("lu").default(false).notNull(),
|
||||||
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
export type MessageCanal = typeof messagesCanaux.$inferSelect;
|
||||||
|
|
||||||
|
// ─── Demandes de Mise en Relation ─────────────────────────────────────────────
|
||||||
|
export const demandesMiseEnRelation = mysqlTable("demandes_mise_en_relation", {
|
||||||
|
id: int("id").autoincrement().primaryKey(),
|
||||||
|
demandeurId: int("demandeurId").notNull(),
|
||||||
|
demandeurNom: varchar("demandeurNom", { length: 255 }).notNull(),
|
||||||
|
demandeurEmail: varchar("demandeurEmail", { length: 320 }),
|
||||||
|
etablissementDemandeurId: int("etablissementDemandeurId"),
|
||||||
|
sujet: varchar("sujet", { length: 500 }).notNull(),
|
||||||
|
message: text("message").notNull(),
|
||||||
|
statut: mysqlEnum("statut", ["en_attente", "traite", "ferme"]).default("en_attente").notNull(),
|
||||||
|
canalId: int("canalId"),
|
||||||
|
traitePar: int("traitePar"),
|
||||||
|
traiteAt: timestamp("traiteAt"),
|
||||||
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||||
|
});
|
||||||
|
export type DemandeMiseEnRelation = typeof demandesMiseEnRelation.$inferSelect;
|
||||||
|
|||||||
226
server/db.ts
226
server/db.ts
@@ -3,12 +3,17 @@ import { drizzle } from "drizzle-orm/mysql2";
|
|||||||
import {
|
import {
|
||||||
InsertUser,
|
InsertUser,
|
||||||
blocsFonctionnels,
|
blocsFonctionnels,
|
||||||
|
canauxDiscussion,
|
||||||
consultations,
|
consultations,
|
||||||
demandesContact,
|
demandesContact,
|
||||||
|
demandesMiseEnRelation,
|
||||||
editeurs,
|
editeurs,
|
||||||
etablissements,
|
etablissements,
|
||||||
logicielsEtablissements,
|
logicielsEtablissements,
|
||||||
|
membresCanauxDiscussion,
|
||||||
|
messagesCanaux,
|
||||||
solutions,
|
solutions,
|
||||||
|
userEtablissements,
|
||||||
users,
|
users,
|
||||||
} from "../drizzle/schema";
|
} from "../drizzle/schema";
|
||||||
import { ENV } from "./_core/env";
|
import { ENV } from "./_core/env";
|
||||||
@@ -430,7 +435,7 @@ export async function getDemandeById(id: number) {
|
|||||||
|
|
||||||
// ─── Auth locale ──────────────────────────────────────────────────────────────
|
// ─── Auth locale ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
import { localCredentials, userEtablissements } from "../drizzle/schema";
|
import { localCredentials } from "../drizzle/schema";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
|
|
||||||
@@ -1031,3 +1036,222 @@ export async function setAdherentsForEtablissement(etablissementId: number, user
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Canaux de Discussion ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crée un canal de discussion et ajoute les membres initiaux.
|
||||||
|
*/
|
||||||
|
export async function createCanal(data: {
|
||||||
|
titre: string;
|
||||||
|
description?: string;
|
||||||
|
type: "contact_referent" | "mise_en_relation_public" | "mise_en_relation_prive";
|
||||||
|
visibilite: "public" | "prive";
|
||||||
|
etablissementId?: number;
|
||||||
|
demandeContactId?: number;
|
||||||
|
demandeMiseEnRelationId?: number;
|
||||||
|
creePar: number;
|
||||||
|
membreIds: number[];
|
||||||
|
}) {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) throw new Error("DB unavailable");
|
||||||
|
const [result] = await db.insert(canauxDiscussion).values({
|
||||||
|
titre: data.titre,
|
||||||
|
description: data.description,
|
||||||
|
type: data.type,
|
||||||
|
visibilite: data.visibilite,
|
||||||
|
etablissementId: data.etablissementId,
|
||||||
|
demandeContactId: data.demandeContactId,
|
||||||
|
demandeMiseEnRelationId: data.demandeMiseEnRelationId,
|
||||||
|
creePar: data.creePar,
|
||||||
|
});
|
||||||
|
const canalId = (result as any).insertId as number;
|
||||||
|
if (data.membreIds.length > 0) {
|
||||||
|
await db.insert(membresCanauxDiscussion).values(
|
||||||
|
data.membreIds.map((uid) => ({ canalId, userId: uid, role: "membre" as const }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return canalId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère les canaux accessibles à un utilisateur (membre ou gestionnaire).
|
||||||
|
*/
|
||||||
|
export async function getCanauxForUser(userId: number, isGestionnaire: boolean) {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) return [];
|
||||||
|
if (isGestionnaire) {
|
||||||
|
return db.select().from(canauxDiscussion).orderBy(desc(canauxDiscussion.updatedAt));
|
||||||
|
}
|
||||||
|
// Canaux dont l'utilisateur est membre
|
||||||
|
const memberships = await db
|
||||||
|
.select({ canalId: membresCanauxDiscussion.canalId })
|
||||||
|
.from(membresCanauxDiscussion)
|
||||||
|
.where(eq(membresCanauxDiscussion.userId, userId));
|
||||||
|
const canalIds = memberships.map((m) => m.canalId);
|
||||||
|
if (canalIds.length === 0) return [];
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(canauxDiscussion)
|
||||||
|
.where(inArray(canauxDiscussion.id, canalIds))
|
||||||
|
.orderBy(desc(canauxDiscussion.updatedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère les messages d'un canal.
|
||||||
|
*/
|
||||||
|
export async function getMessagesCanal(canalId: number) {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) return [];
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(messagesCanaux)
|
||||||
|
.where(eq(messagesCanaux.canalId, canalId))
|
||||||
|
.orderBy(messagesCanaux.createdAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envoie un message dans un canal.
|
||||||
|
*/
|
||||||
|
export async function sendMessageCanal(data: {
|
||||||
|
canalId: number;
|
||||||
|
auteurId: number;
|
||||||
|
auteurNom: string;
|
||||||
|
contenu: string;
|
||||||
|
}) {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) throw new Error("DB unavailable");
|
||||||
|
await db.insert(messagesCanaux).values(data);
|
||||||
|
// Mettre à jour updatedAt du canal
|
||||||
|
await db
|
||||||
|
.update(canauxDiscussion)
|
||||||
|
.set({ updatedAt: new Date() })
|
||||||
|
.where(eq(canauxDiscussion.id, data.canalId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie si un utilisateur est membre d'un canal.
|
||||||
|
*/
|
||||||
|
export async function isMemberOfCanal(canalId: number, userId: number): Promise<boolean> {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) return false;
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(membresCanauxDiscussion)
|
||||||
|
.where(and(eq(membresCanauxDiscussion.canalId, canalId), eq(membresCanauxDiscussion.userId, userId)));
|
||||||
|
return rows.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère un canal par son ID.
|
||||||
|
*/
|
||||||
|
export async function getCanalById(id: number) {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) return null;
|
||||||
|
const rows = await db.select().from(canauxDiscussion).where(eq(canauxDiscussion.id, id));
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère les membres d'un canal.
|
||||||
|
*/
|
||||||
|
export async function getMembresCanal(canalId: number) {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) return [];
|
||||||
|
return db
|
||||||
|
.select({
|
||||||
|
id: users.id,
|
||||||
|
name: users.name,
|
||||||
|
email: users.email,
|
||||||
|
sonumRole: users.sonumRole,
|
||||||
|
role: membresCanauxDiscussion.role,
|
||||||
|
})
|
||||||
|
.from(membresCanauxDiscussion)
|
||||||
|
.innerJoin(users, eq(membresCanauxDiscussion.userId, users.id))
|
||||||
|
.where(eq(membresCanauxDiscussion.canalId, canalId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ajoute des membres à un canal existant.
|
||||||
|
*/
|
||||||
|
export async function addMembresCanal(canalId: number, userIds: number[]) {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) return;
|
||||||
|
const existing = await db
|
||||||
|
.select({ userId: membresCanauxDiscussion.userId })
|
||||||
|
.from(membresCanauxDiscussion)
|
||||||
|
.where(eq(membresCanauxDiscussion.canalId, canalId));
|
||||||
|
const existingIds = new Set(existing.map((m) => m.userId));
|
||||||
|
const toAdd = userIds.filter((id) => !existingIds.has(id));
|
||||||
|
if (toAdd.length > 0) {
|
||||||
|
await db.insert(membresCanauxDiscussion).values(
|
||||||
|
toAdd.map((uid) => ({ canalId, userId: uid, role: "membre" as const }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Demandes de Mise en Relation ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crée une demande de mise en relation.
|
||||||
|
*/
|
||||||
|
export async function createDemandeMiseEnRelation(data: {
|
||||||
|
demandeurId: number;
|
||||||
|
demandeurNom: string;
|
||||||
|
demandeurEmail?: string;
|
||||||
|
etablissementDemandeurId?: number;
|
||||||
|
sujet: string;
|
||||||
|
message: string;
|
||||||
|
}) {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) throw new Error("DB unavailable");
|
||||||
|
const [result] = await db.insert(demandesMiseEnRelation).values(data);
|
||||||
|
return (result as any).insertId as number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère toutes les demandes de mise en relation (pour les gestionnaires).
|
||||||
|
*/
|
||||||
|
export async function getAllDemandesMiseEnRelation() {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) return [];
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(demandesMiseEnRelation)
|
||||||
|
.orderBy(desc(demandesMiseEnRelation.createdAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère les demandes de mise en relation d'un utilisateur.
|
||||||
|
*/
|
||||||
|
export async function getDemandesMiseEnRelationByUser(userId: number) {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) return [];
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(demandesMiseEnRelation)
|
||||||
|
.where(eq(demandesMiseEnRelation.demandeurId, userId))
|
||||||
|
.orderBy(desc(demandesMiseEnRelation.createdAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marque une demande de mise en relation comme traitée et lui associe un canal.
|
||||||
|
*/
|
||||||
|
export async function traiterDemandeMiseEnRelation(id: number, traitePar: number, canalId?: number) {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) return;
|
||||||
|
await db
|
||||||
|
.update(demandesMiseEnRelation)
|
||||||
|
.set({ statut: "traite", traitePar, traiteAt: new Date(), canalId: canalId ?? null })
|
||||||
|
.where(eq(demandesMiseEnRelation.id, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère une demande de mise en relation par son ID.
|
||||||
|
*/
|
||||||
|
export async function getDemandeMiseEnRelationById(id: number) {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) return null;
|
||||||
|
const rows = await db.select().from(demandesMiseEnRelation).where(eq(demandesMiseEnRelation.id, id));
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,6 +43,19 @@ import {
|
|||||||
setReferentForEtablissement,
|
setReferentForEtablissement,
|
||||||
setAdherentsForEtablissement,
|
setAdherentsForEtablissement,
|
||||||
updateLocalPassword,
|
updateLocalPassword,
|
||||||
|
createCanal,
|
||||||
|
getCanauxForUser,
|
||||||
|
getMessagesCanal,
|
||||||
|
sendMessageCanal,
|
||||||
|
isMemberOfCanal,
|
||||||
|
getCanalById,
|
||||||
|
getMembresCanal,
|
||||||
|
addMembresCanal,
|
||||||
|
createDemandeMiseEnRelation,
|
||||||
|
getAllDemandesMiseEnRelation,
|
||||||
|
getDemandesMiseEnRelationByUser,
|
||||||
|
traiterDemandeMiseEnRelation,
|
||||||
|
getDemandeMiseEnRelationById,
|
||||||
updateUser,
|
updateUser,
|
||||||
updateUserCgu,
|
updateUserCgu,
|
||||||
updateUserSonumRole,
|
updateUserSonumRole,
|
||||||
@@ -76,10 +89,88 @@ const writeProcedure = protectedProcedure.use(({ ctx, next }) => {
|
|||||||
return next({ ctx });
|
return next({ ctx });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Router principal ─────────────────────────────────────────────────────────
|
/// ─── Canaux de Discussion ────────────────────────────────────────────────────
|
||||||
|
const canauxRouter = router({
|
||||||
|
list: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
const isGestionnaire = ctx.user.sonumRole === "gestionnaire" || ctx.user.role === "admin";
|
||||||
|
return getCanauxForUser(ctx.user.id, isGestionnaire);
|
||||||
|
}),
|
||||||
|
messages: protectedProcedure
|
||||||
|
.input(z.object({ canalId: z.number().int() }))
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
const isGestionnaire = ctx.user.sonumRole === "gestionnaire" || ctx.user.role === "admin";
|
||||||
|
if (!isGestionnaire) {
|
||||||
|
const isMember = await isMemberOfCanal(input.canalId, ctx.user.id);
|
||||||
|
if (!isMember) throw new TRPCError({ code: "FORBIDDEN" });
|
||||||
|
}
|
||||||
|
return getMessagesCanal(input.canalId);
|
||||||
|
}),
|
||||||
|
membres: protectedProcedure
|
||||||
|
.input(z.object({ canalId: z.number().int() }))
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
const isGestionnaire = ctx.user.sonumRole === "gestionnaire" || ctx.user.role === "admin";
|
||||||
|
if (!isGestionnaire) {
|
||||||
|
const isMember = await isMemberOfCanal(input.canalId, ctx.user.id);
|
||||||
|
if (!isMember) throw new TRPCError({ code: "FORBIDDEN" });
|
||||||
|
}
|
||||||
|
return getMembresCanal(input.canalId);
|
||||||
|
}),
|
||||||
|
sendMessage: writeProcedure
|
||||||
|
.input(z.object({ canalId: z.number().int(), contenu: z.string().min(1).max(5000) }))
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const isGestionnaire = ctx.user.sonumRole === "gestionnaire" || ctx.user.role === "admin";
|
||||||
|
if (!isGestionnaire) {
|
||||||
|
const isMember = await isMemberOfCanal(input.canalId, ctx.user.id);
|
||||||
|
if (!isMember) throw new TRPCError({ code: "FORBIDDEN" });
|
||||||
|
}
|
||||||
|
await sendMessageCanal({ canalId: input.canalId, auteurId: ctx.user.id, auteurNom: ctx.user.name ?? "Inconnu", contenu: input.contenu });
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
create: gestionnaireProcedure
|
||||||
|
.input(z.object({
|
||||||
|
titre: z.string().min(1).max(255),
|
||||||
|
description: z.string().optional(),
|
||||||
|
type: z.enum(["contact_referent", "mise_en_relation_public", "mise_en_relation_prive"]),
|
||||||
|
visibilite: z.enum(["public", "prive"]),
|
||||||
|
etablissementId: z.number().int().optional(),
|
||||||
|
demandeMiseEnRelationId: z.number().int().optional(),
|
||||||
|
membreIds: z.array(z.number().int()),
|
||||||
|
}))
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const canalId = await createCanal({ ...input, creePar: ctx.user.id });
|
||||||
|
if (input.demandeMiseEnRelationId) {
|
||||||
|
await traiterDemandeMiseEnRelation(input.demandeMiseEnRelationId, ctx.user.id, canalId);
|
||||||
|
}
|
||||||
|
return { canalId };
|
||||||
|
}),
|
||||||
|
addMembres: gestionnaireProcedure
|
||||||
|
.input(z.object({ canalId: z.number().int(), userIds: z.array(z.number().int()) }))
|
||||||
|
.mutation(async ({ input }) => { await addMembresCanal(input.canalId, input.userIds); return { success: true }; }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Demandes de Mise en Relation ─────────────────────────────────────────────
|
||||||
|
const miseEnRelationRouter = router({
|
||||||
|
soumettre: writeProcedure
|
||||||
|
.input(z.object({ sujet: z.string().min(1).max(500), message: z.string().min(1), etablissementDemandeurId: z.number().int().optional() }))
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const id = await createDemandeMiseEnRelation({
|
||||||
|
demandeurId: ctx.user.id, demandeurNom: ctx.user.name ?? "Inconnu",
|
||||||
|
demandeurEmail: ctx.user.email ?? undefined,
|
||||||
|
etablissementDemandeurId: input.etablissementDemandeurId,
|
||||||
|
sujet: input.sujet, message: input.message,
|
||||||
|
});
|
||||||
|
await notifyOwner({ title: `Nouvelle demande de mise en relation — ${input.sujet}`, content: `${ctx.user.name} (${ctx.user.email}) souhaite une mise en relation.\n\nSujet : ${input.sujet}\n\nMessage : ${input.message}` });
|
||||||
|
return { id };
|
||||||
|
}),
|
||||||
|
all: gestionnaireProcedure.query(() => getAllDemandesMiseEnRelation()),
|
||||||
|
mesDemandes: protectedProcedure.query(({ ctx }) => getDemandesMiseEnRelationByUser(ctx.user.id)),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Router principal ─────────────────────────────────────────────────────────
|
||||||
export const appRouter = router({
|
export const appRouter = router({
|
||||||
system: systemRouter,
|
system: systemRouter,
|
||||||
|
canaux: canauxRouter,
|
||||||
|
miseEnRelation: miseEnRelationRouter,
|
||||||
|
|
||||||
// ─── Auth ──────────────────────────────────────────────────────────────────
|
// ─── Auth ──────────────────────────────────────────────────────────────────
|
||||||
auth: router({
|
auth: router({
|
||||||
|
|||||||
Reference in New Issue
Block a user