diff --git a/client/src/App.tsx b/client/src/App.tsx index dae1b33..e8d0621 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -24,6 +24,7 @@ import ClinicSettings from "@/pages/ClinicSettings"; import ConsultationHistory from "@/pages/ConsultationHistory"; import WhatsAppSetup from "@/pages/WhatsAppSetup"; import SubscriptionBlocked from "@/pages/SubscriptionBlocked"; +import AdminPanel from "@/pages/AdminPanel"; function ProtectedRoute({ children }: { children: React.ReactNode }) { const { isAuthenticated, loading } = useAuth(); @@ -94,6 +95,11 @@ export default function App() { + {/* Admin */} + + + + {/* Fallback */} diff --git a/client/src/components/Layout.tsx b/client/src/components/Layout.tsx index 3d9d726..4aaf44d 100644 --- a/client/src/components/Layout.tsx +++ b/client/src/components/Layout.tsx @@ -3,7 +3,7 @@ import { Link, useLocation } from "wouter"; import { useTranslation } from "react-i18next"; import { LayoutDashboard, Building2, BarChart3, CreditCard, - HelpCircle, LogOut, Stethoscope, Menu, X, + HelpCircle, LogOut, Stethoscope, Menu, X, Shield, } from "lucide-react"; import { useAuth } from "@/_core/hooks/useAuth"; import { cn } from "@/lib/utils"; @@ -50,6 +50,9 @@ export default function Layout({ children }: { children: React.ReactNode }) { { href: "/dashboard/analytics", label: t("nav.analytics"), icon: BarChart3 }, { href: "/dashboard/subscription", label: t("nav.subscription"), icon: CreditCard }, { href: "/help", label: t("nav.help"), icon: HelpCircle }, + ...(user?.role === "admin" + ? [{ href: "/admin", label: "Admin", icon: Shield }] + : []), ]; const isActive = (href: string) => diff --git a/client/src/components/PractitionerManager.tsx b/client/src/components/PractitionerManager.tsx new file mode 100644 index 0000000..f9bf950 --- /dev/null +++ b/client/src/components/PractitionerManager.tsx @@ -0,0 +1,233 @@ +import { useState } from "react"; +import { Loader2, Plus, Trash2, UserPlus, Users } from "lucide-react"; +import { trpc } from "@/lib/trpc"; +import { Button } from "@/components/ui/button"; +import { toast } from "sonner"; + +const PRESET_COLORS = [ + "#10b981", // emerald + "#06b6d4", // cyan + "#0d9488", // teal + "#8b5cf6", // violet + "#f97316", // orange + "#ec4899", // pink + "#3b82f6", // blue + "#eab308", // yellow +]; + +export default function PractitionerManager({ clinicId }: { clinicId: number }) { + const utils = trpc.useUtils(); + const membersQuery = trpc.clinic.listMembers.useQuery( + { clinicId }, + { enabled: clinicId > 0 } + ); + + const [adding, setAdding] = useState(false); + const [email, setEmail] = useState(""); + const [displayName, setDisplayName] = useState(""); + const [color, setColor] = useState(PRESET_COLORS[0]); + + const addMember = trpc.clinic.addMember.useMutation({ + onSuccess: () => { + toast.success("Praticien ajouté"); + utils.clinic.listMembers.invalidate({ clinicId }); + utils.clinic.listMembersPublic.invalidate({ clinicId }); + setEmail(""); + setDisplayName(""); + setColor(PRESET_COLORS[0]); + setAdding(false); + }, + onError: (e) => toast.error(e.message), + }); + + const removeMember = trpc.clinic.removeMember.useMutation({ + onSuccess: () => { + toast.success("Praticien retiré"); + utils.clinic.listMembers.invalidate({ clinicId }); + utils.clinic.listMembersPublic.invalidate({ clinicId }); + }, + onError: (e) => toast.error(e.message), + }); + + const updateMember = trpc.clinic.updateMember.useMutation({ + onSuccess: () => { + utils.clinic.listMembers.invalidate({ clinicId }); + utils.clinic.listMembersPublic.invalidate({ clinicId }); + }, + onError: (e) => toast.error(e.message), + }); + + const handleAdd = () => { + if (!email.trim()) { + toast.error("Email requis"); + return; + } + addMember.mutate({ + clinicId, + email: email.trim(), + displayName: displayName.trim() || undefined, + color, + }); + }; + + const members = membersQuery.data ?? []; + + return ( +
+
+
+
+ +
+
+

Praticiens du cabinet

+

+ Multi-médecins · couleur d'identification +

+
+
+ +
+ + {adding && ( +
+
+ + setEmail(e.target.value)} + placeholder="docteur@example.com" + className="w-full px-3 py-2 rounded-lg border border-slate-200 bg-white text-sm focus:outline-none focus:border-emerald-400" + /> +

+ Le praticien doit déjà avoir un compte QueueMed. +

+
+
+ + setDisplayName(e.target.value)} + placeholder="Dr. Martin" + className="w-full px-3 py-2 rounded-lg border border-slate-200 bg-white text-sm focus:outline-none focus:border-emerald-400" + /> +
+
+ +
+ {PRESET_COLORS.map((c) => ( +
+
+ +
+ )} + + {membersQuery.isLoading ? ( +
+ +
+ ) : members.length === 0 ? ( +

+ Aucun praticien associé. Ajoutez-en un pour activer l'attribution. +

+ ) : ( +
    + {members.map((m) => ( +
  • + + updateMember.mutate({ + clinicId, + memberId: m.id, + color: e.target.value, + }) + } + className="w-9 h-9 rounded-lg border border-slate-200 cursor-pointer flex-shrink-0" + aria-label="Modifier la couleur" + /> +
    +
    + {m.displayName ?? m.name ?? m.email ?? "—"} +
    +
    + {m.email ?? "—"} · {m.role} +
    +
    + +
  • + ))} +
+ )} +
+ ); +} diff --git a/client/src/pages/AdminPanel.tsx b/client/src/pages/AdminPanel.tsx new file mode 100644 index 0000000..c70b2c3 --- /dev/null +++ b/client/src/pages/AdminPanel.tsx @@ -0,0 +1,456 @@ +import { useState } from "react"; +import { Helmet } from "react-helmet-async"; +import { Redirect } from "wouter"; +import { + Loader2, + Shield, + Users as UsersIcon, + Building2, + Activity, + Search, + CheckCircle2, + XCircle, + MessageCircle, + TrendingUp, +} from "lucide-react"; +import { trpc } from "@/lib/trpc"; +import { useAuth } from "@/_core/hooks/useAuth"; +import { Button } from "@/components/ui/button"; +import { toast } from "sonner"; + +type TabId = "overview" | "users" | "clinics"; + +export default function AdminPanel() { + const { user, loading } = useAuth(); + const [tab, setTab] = useState("overview"); + + if (loading) { + return ( +
+ +
+ ); + } + + if (!user || user.role !== "admin") { + return ; + } + + const TABS: { id: TabId; label: string; icon: typeof Activity }[] = [ + { id: "overview", label: "Vue d'ensemble", icon: Activity }, + { id: "users", label: "Utilisateurs", icon: UsersIcon }, + { id: "clinics", label: "Cabinets", icon: Building2 }, + ]; + + return ( +
+ + Admin — QueueMed + + + +
+
+ +
+
+

Administration

+

+ Console réservée aux administrateurs QueueMed. +

+
+
+ +
+ {TABS.map((t) => { + const Icon = t.icon; + const active = tab === t.id; + return ( + + ); + })} +
+ + {tab === "overview" && } + {tab === "users" && } + {tab === "clinics" && } +
+ ); +} + +function OverviewTab() { + const overviewQuery = trpc.admin.getOverview.useQuery(undefined, { + refetchInterval: 30_000, + }); + + if (overviewQuery.isLoading) { + return ( +
+ +
+ ); + } + + const data = overviewQuery.data; + if (!data) { + return ( +
+ Impossible de charger les données. +
+ ); + } + + const cards: { + label: string; + value: number | string; + icon: typeof UsersIcon; + color: string; + sub?: string; + }[] = [ + { + label: "Utilisateurs total", + value: data.totalUsers, + icon: UsersIcon, + color: "from-emerald-500 to-teal-500", + sub: `${data.totalAdmins} admins · ${data.totalDisabled} désactivés`, + }, + { + label: "Cabinets total", + value: data.totalClinics, + icon: Building2, + color: "from-cyan-500 to-blue-500", + sub: `${data.totalActiveClinics} actifs`, + }, + { + label: "Patients aujourd'hui", + value: data.totalQueueEntriesToday, + icon: Activity, + color: "from-orange-500 to-amber-500", + sub: `${data.totalQueueEntriesAllTime} au total`, + }, + { + label: "Sessions WhatsApp", + value: data.activeWhatsAppSessions, + icon: MessageCircle, + color: "from-violet-500 to-purple-500", + sub: "actives en mémoire", + }, + ]; + + return ( +
+ {cards.map((c) => { + const Icon = c.icon; + return ( +
+
+ +
+
+ {c.value} +
+
{c.label}
+ {c.sub &&
{c.sub}
} +
+ ); + })} +
+ ); +} + +function UsersTab() { + const utils = trpc.useUtils(); + const { user } = useAuth(); + const [page, setPage] = useState(1); + const [search, setSearch] = useState(""); + const [roleFilter, setRoleFilter] = useState<"" | "user" | "admin">(""); + + const usersQuery = trpc.admin.listUsers.useQuery({ + page, + perPage: 20, + role: roleFilter || undefined, + search: search || undefined, + }); + + const updateRole = trpc.admin.updateUserRole.useMutation({ + onSuccess: () => { + toast.success("Rôle mis à jour"); + utils.admin.listUsers.invalidate(); + utils.admin.getOverview.invalidate(); + }, + onError: (e) => toast.error(e.message), + }); + + const disableUser = trpc.admin.disableUser.useMutation({ + onSuccess: () => { + toast.success("Statut mis à jour"); + utils.admin.listUsers.invalidate(); + utils.admin.getOverview.invalidate(); + }, + onError: (e) => toast.error(e.message), + }); + + const data = usersQuery.data; + const totalPages = data ? Math.max(1, Math.ceil(data.total / data.perPage)) : 1; + + return ( +
+
+
+ + { + setSearch(e.target.value); + setPage(1); + }} + placeholder="Rechercher par email ou nom…" + className="w-full pl-9 pr-3 py-2 rounded-xl border border-slate-200 bg-white text-sm focus:outline-none focus:border-emerald-400" + /> +
+ +
+ +
+ {usersQuery.isLoading ? ( +
+ +
+ ) : !data || data.users.length === 0 ? ( +
+ Aucun utilisateur trouvé. +
+ ) : ( +
+ + + + + + + + + + + + + {data.users.map((u) => { + const isSelf = u.id === user?.id; + return ( + + + + + + + + + ); + })} + +
EmailNomRôleStatutCréé leActions
+ {u.email} + + {u.name ?? "—"} + + + + {u.disabled ? ( + + Désactivé + + ) : ( + + Actif + + )} + + {u.createdAt + ? new Date(u.createdAt).toLocaleDateString("fr-FR") + : "—"} + + +
+
+ )} + + {data && data.total > data.perPage && ( +
+ + {(data.page - 1) * data.perPage + 1}– + {Math.min(data.page * data.perPage, data.total)} sur {data.total} + +
+ + +
+
+ )} +
+
+ ); +} + +function ClinicsTab() { + const clinicsQuery = trpc.admin.listAllClinics.useQuery(); + + if (clinicsQuery.isLoading) { + return ( +
+ +
+ ); + } + + const clinics = clinicsQuery.data ?? []; + + if (clinics.length === 0) { + return ( +
+ Aucun cabinet enregistré. +
+ ); + } + + return ( +
+
+ + + + + + + + + + + + {clinics.map((c) => ( + + + + + + + + ))} + +
CabinetPropriétairePatients aujourd'huiStatutCréé le
+
{c.name}
+
#{c.id}
+
+
+ {c.ownerEmail ?? "—"} +
+ {c.ownerName && ( +
+ {c.ownerName} +
+ )} +
+ + + {c.patientCountToday} + + +
+ + {c.isActive ? "actif" : "inactif"} + + + {c.isQueueOpen ? "file ouverte" : "file fermée"} + +
+
+ {c.createdAt + ? new Date(c.createdAt).toLocaleDateString("fr-FR") + : "—"} +
+
+
+ ); +} diff --git a/client/src/pages/ClinicSettings.tsx b/client/src/pages/ClinicSettings.tsx index 3ba600b..39eb17a 100644 --- a/client/src/pages/ClinicSettings.tsx +++ b/client/src/pages/ClinicSettings.tsx @@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next"; import { trpc } from "@/lib/trpc"; import { useAuth } from "@/_core/hooks/useAuth"; import { Button } from "@/components/ui/button"; +import PractitionerManager from "@/components/PractitionerManager"; import { toast } from "sonner"; import { Settings, Clock, Globe, MessageSquare, Timer, Users, Save, @@ -346,6 +347,9 @@ export default function ClinicSettings() { {t("clinicSettings.saveButton")} + + {/* Praticiens (multi-praticiens) */} + {clinicId > 0 && } )} diff --git a/client/src/pages/QueueManagement.tsx b/client/src/pages/QueueManagement.tsx index 28918f6..5c3d772 100644 --- a/client/src/pages/QueueManagement.tsx +++ b/client/src/pages/QueueManagement.tsx @@ -4,7 +4,7 @@ import { Helmet } from "react-helmet-async"; import { useTranslation } from "react-i18next"; import { ChevronLeft, Play, UserX, CheckCircle2, Trash2, Monitor, Users, Clock, - Printer, RefreshCw, Loader2, Power, PowerOff, QrCode, Sparkles, + Printer, RefreshCw, Loader2, Power, PowerOff, QrCode, Sparkles, Stethoscope, } from "lucide-react"; import { trpc } from "@/lib/trpc"; import { Button } from "@/components/ui/button"; @@ -25,6 +25,11 @@ export default function QueueManagement() { { enabled: !!clinicId, refetchInterval: 15_000 } ); + const membersQuery = trpc.clinic.listMembers.useQuery( + { clinicId }, + { enabled: !!clinicId } + ); + const baseUrl = typeof window !== "undefined" ? window.location.origin : undefined; const qrQuery = trpc.clinic.qrDataUrl.useQuery( { id: clinicId, baseUrl }, @@ -104,7 +109,16 @@ export default function QueueManagement() { // ─── Derived ───────────────────────────────────────── const data = queueQuery.data; const clinic = data?.clinic; - const queue = data?.queue ?? []; + const allQueue = data?.queue ?? []; + const members = membersQuery.data ?? []; + const memberById = new Map(members.map((m) => [m.id, m])); + + const [practitionerFilter, setPractitionerFilter] = useState(null); + const [practitionerForCall, setPractitionerForCall] = useState(null); + + const queue = practitionerFilter + ? allQueue.filter((e) => e.practitionerId === practitionerFilter) + : allQueue; const waiting = queue.filter((e) => e.status === "waiting"); const called = queue.filter((e) => e.status === "called" || e.status === "in_consultation"); @@ -187,11 +201,41 @@ export default function QueueManagement() { {/* Actions */}

{t("queue.actions")}

+ + {members.length > 0 && ( +
+ + +
+ )} + + {members.map((m) => ( + + ))} +
+ )} + {t("queue.patientCount", { count: queue.length })} + {queueQuery.isLoading ? ( @@ -336,6 +421,19 @@ export default function QueueManagement() { {entry.isPrinted && ( {t("queue.printed")} )} + {entry.practitionerId && memberById.get(entry.practitionerId) && ( + + + {memberById.get(entry.practitionerId)?.displayName ?? + memberById.get(entry.practitionerId)?.name ?? + `#${entry.practitionerId}`} + + )}
{t("queue.posShort")} {entry.position} @@ -353,7 +451,14 @@ export default function QueueManagement() {
{entry.status === "waiting" && (