feat: Phase 3 frontend — AdminPanel, PractitionerManager, practitioner filter in QueueManagement

This commit is contained in:
Hermes 2026-04-25 17:14:16 +00:00
parent f3b9b636fb
commit d495cfc033
6 changed files with 814 additions and 7 deletions

View file

@ -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() {
<ProtectedRoute><SubscriptionBlocked /></ProtectedRoute>
</Route>
{/* Admin */}
<Route path="/admin">
<ProtectedRoute><AdminPanel /></ProtectedRoute>
</Route>
{/* Fallback */}
<Route>
<NotFound />

View file

@ -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) =>

View file

@ -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 (
<div className="glass-card rounded-2xl p-5 space-y-4">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-emerald-100 flex items-center justify-center">
<Users className="w-4 h-4 text-emerald-700" />
</div>
<div>
<h3 className="font-semibold text-slate-900">Praticiens du cabinet</h3>
<p className="text-xs text-slate-500">
Multi-médecins · couleur d'identification
</p>
</div>
</div>
<Button
size="sm"
variant={adding ? "ghost" : "outline"}
onClick={() => setAdding((v) => !v)}
>
{adding ? (
"Annuler"
) : (
<>
<Plus className="w-3.5 h-3.5 mr-1" /> Ajouter
</>
)}
</Button>
</div>
{adding && (
<div className="rounded-xl border border-emerald-200/70 bg-emerald-50/40 p-4 space-y-3">
<div>
<label className="block text-xs font-bold text-slate-600 mb-1">
Email du praticien
</label>
<input
type="email"
value={email}
onChange={(e) => 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"
/>
<p className="text-xs text-slate-500 mt-1">
Le praticien doit déjà avoir un compte QueueMed.
</p>
</div>
<div>
<label className="block text-xs font-bold text-slate-600 mb-1">
Nom affiché (optionnel)
</label>
<input
type="text"
value={displayName}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-xs font-bold text-slate-600 mb-2">
Couleur d'identification
</label>
<div className="flex flex-wrap gap-2">
{PRESET_COLORS.map((c) => (
<button
key={c}
onClick={() => setColor(c)}
className={`w-8 h-8 rounded-lg border-2 transition-all ${
color === c
? "border-slate-900 scale-110 shadow-md"
: "border-white"
}`}
style={{ background: c }}
aria-label={`Couleur ${c}`}
/>
))}
<input
type="color"
value={color}
onChange={(e) => setColor(e.target.value)}
className="w-8 h-8 rounded-lg cursor-pointer"
aria-label="Couleur personnalisée"
/>
</div>
</div>
<Button
variant="gradient"
size="sm"
className="w-full"
onClick={handleAdd}
disabled={addMember.isPending}
>
{addMember.isPending ? (
<Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" />
) : (
<UserPlus className="w-3.5 h-3.5 mr-1" />
)}
Ajouter le praticien
</Button>
</div>
)}
{membersQuery.isLoading ? (
<div className="flex items-center justify-center py-6">
<Loader2 className="w-5 h-5 text-emerald-500 animate-spin" />
</div>
) : members.length === 0 ? (
<p className="text-center text-sm text-slate-500 py-6">
Aucun praticien associé. Ajoutez-en un pour activer l'attribution.
</p>
) : (
<ul className="divide-y divide-slate-100">
{members.map((m) => (
<li
key={m.id}
className="flex items-center gap-3 py-3 first:pt-0 last:pb-0"
>
<input
type="color"
value={m.color ?? "#10b981"}
onChange={(e) =>
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"
/>
<div className="flex-1 min-w-0">
<div className="font-semibold text-slate-900 text-sm truncate">
{m.displayName ?? m.name ?? m.email ?? "—"}
</div>
<div className="text-xs text-slate-500 truncate">
{m.email ?? "—"} · {m.role}
</div>
</div>
<button
onClick={() =>
removeMember.mutate({ clinicId, memberId: m.id })
}
disabled={removeMember.isPending || m.role === "owner"}
className="w-8 h-8 rounded-lg flex items-center justify-center text-red-600 hover:bg-red-50 disabled:opacity-30"
title={
m.role === "owner"
? "Le propriétaire ne peut être retiré"
: "Retirer ce praticien"
}
>
<Trash2 className="w-4 h-4" />
</button>
</li>
))}
</ul>
)}
</div>
);
}

View file

@ -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<TabId>("overview");
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<Loader2 className="w-8 h-8 text-emerald-500 animate-spin" />
</div>
);
}
if (!user || user.role !== "admin") {
return <Redirect to="/dashboard" />;
}
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 (
<div className="container py-8">
<Helmet>
<title>Admin QueueMed</title>
<meta name="description" content="Console d'administration QueueMed." />
</Helmet>
<div className="flex items-center gap-3 mb-6">
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-emerald-500 to-cyan-500 flex items-center justify-center shadow-md">
<Shield className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="font-bold text-2xl">Administration</h1>
<p className="text-slate-500 text-sm">
Console réservée aux administrateurs QueueMed.
</p>
</div>
</div>
<div className="glass-card rounded-2xl p-1.5 mb-6 inline-flex flex-wrap gap-1">
{TABS.map((t) => {
const Icon = t.icon;
const active = tab === t.id;
return (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-all ${
active
? "bg-gradient-to-r from-emerald-500 to-cyan-500 text-white shadow-md"
: "text-slate-600 hover:bg-emerald-50"
}`}
>
<Icon className="w-4 h-4" />
{t.label}
</button>
);
})}
</div>
{tab === "overview" && <OverviewTab />}
{tab === "users" && <UsersTab />}
{tab === "clinics" && <ClinicsTab />}
</div>
);
}
function OverviewTab() {
const overviewQuery = trpc.admin.getOverview.useQuery(undefined, {
refetchInterval: 30_000,
});
if (overviewQuery.isLoading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-8 h-8 text-emerald-500 animate-spin" />
</div>
);
}
const data = overviewQuery.data;
if (!data) {
return (
<div className="glass-card rounded-2xl p-8 text-center text-slate-500">
Impossible de charger les données.
</div>
);
}
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 (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{cards.map((c) => {
const Icon = c.icon;
return (
<div key={c.label} className="glass-card rounded-2xl p-5">
<div
className={`w-10 h-10 rounded-xl bg-gradient-to-br ${c.color} flex items-center justify-center mb-4 shadow-md`}
>
<Icon className="w-5 h-5 text-white" />
</div>
<div className="font-bold text-3xl text-slate-900 mb-1 tabular-nums">
{c.value}
</div>
<div className="text-sm text-slate-700 font-medium">{c.label}</div>
{c.sub && <div className="text-xs text-slate-500 mt-1">{c.sub}</div>}
</div>
);
})}
</div>
);
}
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 (
<div className="space-y-4">
<div className="glass-card rounded-2xl p-4 flex flex-col sm:flex-row gap-3 sm:items-center">
<div className="relative flex-1">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input
type="text"
value={search}
onChange={(e) => {
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"
/>
</div>
<select
value={roleFilter}
onChange={(e) => {
setRoleFilter(e.target.value as "" | "user" | "admin");
setPage(1);
}}
className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm focus:outline-none focus:border-emerald-400"
aria-label="Filtrer par rôle"
>
<option value="">Tous les rôles</option>
<option value="user">Utilisateurs</option>
<option value="admin">Administrateurs</option>
</select>
</div>
<div className="glass-card rounded-2xl overflow-hidden">
{usersQuery.isLoading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-6 h-6 text-emerald-500 animate-spin" />
</div>
) : !data || data.users.length === 0 ? (
<div className="text-center py-16 px-6 text-slate-500">
Aucun utilisateur trouvé.
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-emerald-50/60 text-slate-600 text-xs uppercase tracking-wider">
<tr>
<th className="text-left px-4 py-3 font-semibold">Email</th>
<th className="text-left px-4 py-3 font-semibold">Nom</th>
<th className="text-left px-4 py-3 font-semibold">Rôle</th>
<th className="text-left px-4 py-3 font-semibold">Statut</th>
<th className="text-left px-4 py-3 font-semibold">Créé le</th>
<th className="text-right px-4 py-3 font-semibold">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{data.users.map((u) => {
const isSelf = u.id === user?.id;
return (
<tr key={u.id} className="hover:bg-emerald-50/30">
<td className="px-4 py-3 font-medium text-slate-900 truncate max-w-[280px]">
{u.email}
</td>
<td className="px-4 py-3 text-slate-600">
{u.name ?? "—"}
</td>
<td className="px-4 py-3">
<select
value={u.role ?? "user"}
onChange={(e) =>
updateRole.mutate({
userId: u.id,
role: e.target.value as "user" | "admin",
})
}
disabled={isSelf || updateRole.isPending}
className="rounded-lg border border-slate-200 bg-white px-2 py-1 text-xs focus:outline-none focus:border-emerald-400 disabled:opacity-60"
aria-label="Rôle"
>
<option value="user">user</option>
<option value="admin">admin</option>
</select>
</td>
<td className="px-4 py-3">
{u.disabled ? (
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-md bg-red-100 text-red-700 text-xs font-bold">
<XCircle className="w-3 h-3" /> Désactivé
</span>
) : (
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-md bg-emerald-100 text-emerald-700 text-xs font-bold">
<CheckCircle2 className="w-3 h-3" /> Actif
</span>
)}
</td>
<td className="px-4 py-3 text-slate-500 text-xs">
{u.createdAt
? new Date(u.createdAt).toLocaleDateString("fr-FR")
: "—"}
</td>
<td className="px-4 py-3 text-right">
<Button
size="sm"
variant={u.disabled ? "outline" : "ghost"}
disabled={isSelf || disableUser.isPending}
onClick={() =>
disableUser.mutate({
userId: u.id,
disabled: !u.disabled,
})
}
className={
u.disabled
? ""
: "text-red-600 hover:bg-red-50 hover:text-red-700"
}
>
{u.disabled ? "Réactiver" : "Désactiver"}
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{data && data.total > data.perPage && (
<div className="flex items-center justify-between p-4 border-t border-slate-100 text-sm">
<span className="text-slate-500">
{(data.page - 1) * data.perPage + 1}
{Math.min(data.page * data.perPage, data.total)} sur {data.total}
</span>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
Précédent
</Button>
<Button
size="sm"
variant="outline"
disabled={page >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
Suivant
</Button>
</div>
</div>
)}
</div>
</div>
);
}
function ClinicsTab() {
const clinicsQuery = trpc.admin.listAllClinics.useQuery();
if (clinicsQuery.isLoading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-6 h-6 text-emerald-500 animate-spin" />
</div>
);
}
const clinics = clinicsQuery.data ?? [];
if (clinics.length === 0) {
return (
<div className="glass-card rounded-2xl p-8 text-center text-slate-500">
Aucun cabinet enregistré.
</div>
);
}
return (
<div className="glass-card rounded-2xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-emerald-50/60 text-slate-600 text-xs uppercase tracking-wider">
<tr>
<th className="text-left px-4 py-3 font-semibold">Cabinet</th>
<th className="text-left px-4 py-3 font-semibold">Propriétaire</th>
<th className="text-left px-4 py-3 font-semibold">Patients aujourd'hui</th>
<th className="text-left px-4 py-3 font-semibold">Statut</th>
<th className="text-left px-4 py-3 font-semibold">Créé le</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{clinics.map((c) => (
<tr key={c.id} className="hover:bg-emerald-50/30">
<td className="px-4 py-3">
<div className="font-semibold text-slate-900">{c.name}</div>
<div className="text-xs text-slate-500">#{c.id}</div>
</td>
<td className="px-4 py-3">
<div className="text-slate-700 truncate max-w-[260px]">
{c.ownerEmail ?? "—"}
</div>
{c.ownerName && (
<div className="text-xs text-slate-500 truncate max-w-[260px]">
{c.ownerName}
</div>
)}
</td>
<td className="px-4 py-3">
<span className="inline-flex items-center gap-1 font-bold text-emerald-700">
<TrendingUp className="w-3.5 h-3.5" />
{c.patientCountToday}
</span>
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1.5">
<span
className={`px-2 py-0.5 rounded-md text-xs font-bold ${
c.isActive
? "bg-emerald-100 text-emerald-700"
: "bg-slate-100 text-slate-500"
}`}
>
{c.isActive ? "actif" : "inactif"}
</span>
<span
className={`px-2 py-0.5 rounded-md text-xs font-bold ${
c.isQueueOpen
? "bg-cyan-100 text-cyan-700"
: "bg-slate-100 text-slate-500"
}`}
>
{c.isQueueOpen ? "file ouverte" : "file fermée"}
</span>
</div>
</td>
<td className="px-4 py-3 text-slate-500 text-xs">
{c.createdAt
? new Date(c.createdAt).toLocaleDateString("fr-FR")
: "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -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")}
</Button>
</div>
{/* Praticiens (multi-praticiens) */}
{clinicId > 0 && <PractitionerManager clinicId={clinicId} />}
</>
)}
</div>

View file

@ -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<number | null>(null);
const [practitionerForCall, setPractitionerForCall] = useState<number | null>(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 */}
<div className="glass-card rounded-2xl p-6">
<h2 className="text-xs font-bold text-slate-500 uppercase tracking-widest mb-4">{t("queue.actions")}</h2>
{members.length > 0 && (
<div className="mb-3">
<label className="flex items-center gap-1.5 text-[11px] font-bold text-slate-500 uppercase tracking-widest mb-1.5">
<Stethoscope className="w-3 h-3" />
Praticien assigné
</label>
<select
value={practitionerForCall ?? ""}
onChange={(e) =>
setPractitionerForCall(e.target.value ? Number(e.target.value) : null)
}
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm focus:outline-none focus:border-emerald-400"
aria-label="Praticien assigné"
>
<option value=""> Sans assignation </option>
{members.map((m) => (
<option key={m.id} value={m.id}>
{m.displayName ?? m.name ?? m.email ?? `Praticien #${m.id}`}
</option>
))}
</select>
</div>
)}
<Button
variant="gradient"
size="xl"
className="w-full mb-3"
onClick={() => callNext.mutate({ clinicId })}
onClick={() =>
callNext.mutate({
clinicId,
...(practitionerForCall ? { practitionerId: practitionerForCall } : {}),
})
}
disabled={callNext.isPending || waiting.length === 0}
>
{callNext.isPending ? <Loader2 className="w-5 h-5 animate-spin mr-2" /> : <Play className="w-5 h-5 mr-2" />}
@ -277,9 +321,50 @@ export default function QueueManagement() {
{/* ─── Right: Queue list ───────────────────────── */}
<div className="lg:col-span-2">
<div className="glass-card rounded-2xl overflow-hidden">
<div className="p-4 border-b border-slate-100 flex items-center justify-between">
<div className="p-4 border-b border-slate-100 flex items-center justify-between gap-3 flex-wrap">
<h2 className="font-bold">{t("queue.queueListTitle")}</h2>
<span className="text-slate-500 text-sm">{t("queue.patientCount", { count: queue.length })}</span>
<div className="flex items-center gap-3 ml-auto">
{members.length > 0 && (
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[11px] font-bold text-slate-500 uppercase tracking-widest">
Filtre
</span>
<button
onClick={() => setPractitionerFilter(null)}
className={`px-2.5 py-1 rounded-lg text-xs font-medium border transition-all ${
practitionerFilter === null
? "bg-teal-600 text-white border-teal-600"
: "bg-white border-slate-200 text-slate-600 hover:border-emerald-400"
}`}
>
Tous
</button>
{members.map((m) => (
<button
key={m.id}
onClick={() => setPractitionerFilter(m.id)}
className={`flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium border transition-all ${
practitionerFilter === m.id
? "text-white border-transparent shadow-md"
: "bg-white border-slate-200 text-slate-700 hover:border-emerald-400"
}`}
style={
practitionerFilter === m.id
? { background: m.color ?? "#10b981" }
: undefined
}
>
<span
className="w-2 h-2 rounded-full"
style={{ background: m.color ?? "#10b981" }}
/>
{m.displayName ?? m.name ?? m.email ?? `#${m.id}`}
</button>
))}
</div>
)}
<span className="text-slate-500 text-sm">{t("queue.patientCount", { count: queue.length })}</span>
</div>
</div>
{queueQuery.isLoading ? (
@ -336,6 +421,19 @@ export default function QueueManagement() {
{entry.isPrinted && (
<span className="text-[10px] text-slate-500 bg-slate-100 rounded px-1.5 py-0.5">{t("queue.printed")}</span>
)}
{entry.practitionerId && memberById.get(entry.practitionerId) && (
<span
className="inline-flex items-center gap-1 text-[10px] font-bold rounded px-1.5 py-0.5 text-white"
style={{
background: memberById.get(entry.practitionerId)?.color ?? "#10b981",
}}
>
<Stethoscope className="w-2.5 h-2.5" />
{memberById.get(entry.practitionerId)?.displayName ??
memberById.get(entry.practitionerId)?.name ??
`#${entry.practitionerId}`}
</span>
)}
</div>
<div className="flex items-center gap-2 text-xs text-slate-500">
<span>{t("queue.posShort")} {entry.position}</span>
@ -353,7 +451,14 @@ export default function QueueManagement() {
<div className="flex items-center gap-1 flex-shrink-0">
{entry.status === "waiting" && (
<button
onClick={() => callSpecific.mutate({ entryId: entry.id })}
onClick={() =>
callSpecific.mutate({
entryId: entry.id,
...(practitionerForCall
? { practitionerId: practitionerForCall }
: {}),
})
}
className="w-8 h-8 rounded-lg flex items-center justify-center text-emerald-600 hover:bg-emerald-100"
title={t("queue.callThisPatient")}
>