feat: Phase 3 frontend — AdminPanel, PractitionerManager, practitioner filter in QueueManagement
This commit is contained in:
parent
f3b9b636fb
commit
d495cfc033
6 changed files with 814 additions and 7 deletions
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
233
client/src/components/PractitionerManager.tsx
Normal file
233
client/src/components/PractitionerManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
456
client/src/pages/AdminPanel.tsx
Normal file
456
client/src/pages/AdminPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue