queue-med/client/src/pages/QueueManagement.tsx

511 lines
24 KiB
TypeScript

import { useEffect, useState } from "react";
import { useParams, useLocation } from "wouter";
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, Stethoscope,
} from "lucide-react";
import { trpc } from "@/lib/trpc";
import { Button } from "@/components/ui/button";
import { getSocket } from "@/lib/socket";
import { toast } from "sonner";
import { formatTicket, formatTime } from "@/lib/utils";
import type { QueueEntryStatus } from "@shared/types";
export default function QueueManagement() {
const { t } = useTranslation();
const params = useParams<{ clinicId: string }>();
const [, navigate] = useLocation();
const clinicId = Number(params.clinicId ?? 0);
const utils = trpc.useUtils();
const queueQuery = trpc.queue.getForDoctor.useQuery(
{ clinicId },
{ 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 },
{ enabled: !!clinicId, refetchInterval: 60_000 }
);
// ─── Socket ──────────────────────────────────────────
useEffect(() => {
if (!clinicId) return;
const s = getSocket();
s.emit("clinic:subscribe", clinicId);
const onUpdate = () => utils.queue.getForDoctor.invalidate({ clinicId });
const onQr = () => utils.clinic.qrDataUrl.invalidate({ id: clinicId, baseUrl });
s.on("queue:update", onUpdate);
s.on("qr:rotated", onQr);
return () => {
s.emit("clinic:unsubscribe", clinicId);
s.off("queue:update", onUpdate);
s.off("qr:rotated", onQr);
};
}, [clinicId, utils, baseUrl]);
// ─── Mutations ───────────────────────────────────────
const callNext = trpc.queue.callNext.useMutation({
onSuccess: (d) => {
if (d.called) toast.success(t("queue.toastTicketCalled", { number: formatTicket(d.called.ticketNumber) }));
else toast(t("queue.noPatients"));
utils.queue.getForDoctor.invalidate({ clinicId });
},
onError: (e) => toast.error(e.message),
});
const markAbsent = trpc.queue.markAbsent.useMutation({
onSuccess: () => { toast.success(t("queue.toastPatientAbsent")); utils.queue.getForDoctor.invalidate({ clinicId }); },
onError: (e) => toast.error(e.message),
});
const markDone = trpc.queue.markDone.useMutation({
onSuccess: () => { toast.success(t("queue.toastConsultDone")); utils.queue.getForDoctor.invalidate({ clinicId }); },
onError: (e) => toast.error(e.message),
});
const callSpecific = trpc.queue.callSpecific.useMutation({
onSuccess: () => { toast.success(t("queue.toastPatientCalled")); utils.queue.getForDoctor.invalidate({ clinicId }); },
onError: (e) => toast.error(e.message),
});
const reset = trpc.queue.reset.useMutation({
onSuccess: () => { toast.success(t("queue.toastQueueReset")); utils.queue.getForDoctor.invalidate({ clinicId }); },
onError: (e) => toast.error(e.message),
});
const printTicket = trpc.queue.joinPrinted.useMutation({
onSuccess: (d) => {
toast.success(t("queue.toastTicketCreated", { number: formatTicket(d.ticketNumber) }));
window.open(`/ticket/${d.entryId}`, "_blank");
utils.queue.getForDoctor.invalidate({ clinicId });
},
onError: (e) => toast.error(e.message),
});
const toggleQueue = trpc.clinic.update.useMutation({
onSuccess: () => { utils.queue.getForDoctor.invalidate({ clinicId }); utils.clinic.list.invalidate(); },
onError: (e) => toast.error(e.message),
});
const reorder = trpc.queue.reorder.useMutation({
onSuccess: () => utils.queue.getForDoctor.invalidate({ clinicId }),
onError: (e) => { toast.error(e.message); utils.queue.getForDoctor.invalidate({ clinicId }); },
});
const regenQr = trpc.clinic.regenerateQr.useMutation({
onSuccess: () => { toast.success(t("queue.toastQrRegenerated")); utils.clinic.qrDataUrl.invalidate({ id: clinicId, baseUrl }); },
onError: (e) => toast.error(e.message),
});
// ─── Derived ─────────────────────────────────────────
const data = queueQuery.data;
const clinic = data?.clinic;
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");
const [confirmReset, setConfirmReset] = useState(false);
// ─── Drag & drop reordering ──────────────────────────
const [dragId, setDragId] = useState<number | null>(null);
const [dragOverId, setDragOverId] = useState<number | null>(null);
const handleDrop = (target: number) => {
if (dragId === null || dragId === target) {
setDragId(null);
setDragOverId(null);
return;
}
const ids = waiting.map((e) => e.id);
const fromIdx = ids.indexOf(dragId);
const toIdx = ids.indexOf(target);
if (fromIdx < 0 || toIdx < 0) return;
const reordered = [...ids];
const [moved] = reordered.splice(fromIdx, 1);
reordered.splice(toIdx, 0, moved);
reorder.mutate({ clinicId, orderedEntryIds: reordered });
setDragId(null);
setDragOverId(null);
};
if (!clinicId) {
return (
<div className="container py-12 text-center">
<Helmet>
<title>{t("queue.metaTitle")}</title>
<meta name="description" content={t("queue.metaDescription")} />
</Helmet>
<p className="text-slate-500">{t("queue.clinicNotFound")}</p>
</div>
);
}
return (
<div className="container py-6">
<Helmet>
<title>{t("queue.metaTitle")}</title>
<meta name="description" content={t("queue.metaDescription")} />
</Helmet>
{/* ─── Header ───────────────────────────────────────── */}
<div className="flex items-center justify-between mb-6 gap-3">
<Button variant="ghost" size="sm" onClick={() => navigate("/dashboard")}>
<ChevronLeft className="w-4 h-4 mr-1" /> {t("common.back")}
</Button>
<div className="text-center min-w-0 flex-1">
<h1 className="font-bold text-xl gradient-text truncate">{clinic?.name ?? t("common.loading")}</h1>
<p className="text-slate-500 text-xs">
{t("queue.headerCounts", { waiting: waiting.length, called: called.length })}
</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => window.open(`/display/${clinicId}`, "_blank")}
title={t("queue.displayScreen")}
>
<Monitor className="w-4 h-4" />
</Button>
<Button
size="sm"
variant={clinic?.isQueueOpen ? "destructive" : "gradient"}
onClick={() => toggleQueue.mutate({ id: clinicId, isQueueOpen: !clinic?.isQueueOpen })}
disabled={toggleQueue.isPending}
>
{clinic?.isQueueOpen ? <><PowerOff className="w-4 h-4 mr-1" />{t("queue.closeShort")}</> : <><Power className="w-4 h-4 mr-1" />{t("queue.openShort")}</>}
</Button>
</div>
</div>
<div className="grid lg:grid-cols-3 gap-6">
{/* ─── Left: Controls / QR / Stats ─────────────── */}
<div className="space-y-4">
{/* 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,
...(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" />}
{t("queue.callNext")}
</Button>
<Button
variant="outline"
className="w-full mb-3"
onClick={() => printTicket.mutate({ clinicId })}
disabled={printTicket.isPending || !clinic?.isQueueOpen}
>
<Printer className="w-4 h-4 mr-2" /> {t("queue.printTicket")}
</Button>
<Button
variant="outline"
className="w-full text-red-600 border-red-200 hover:bg-red-50"
onClick={() => setConfirmReset(true)}
disabled={reset.isPending}
>
<RefreshCw className="w-4 h-4 mr-2" /> {t("queue.resetQueue")}
</Button>
{confirmReset && (
<div className="mt-3 p-3 rounded-xl bg-red-50 border border-red-200 text-sm text-red-800">
<p className="mb-3">{t("queue.resetConfirm")}</p>
<div className="flex gap-2">
<Button size="sm" variant="destructive" onClick={() => { reset.mutate({ clinicId }); setConfirmReset(false); }} disabled={reset.isPending}>
{t("queue.resetYes")}
</Button>
<Button size="sm" variant="outline" onClick={() => setConfirmReset(false)}>{t("common.cancel")}</Button>
</div>
</div>
)}
</div>
{/* QR */}
<div className="glass-card rounded-2xl p-6">
<h2 className="text-xs font-bold text-slate-500 uppercase tracking-widest mb-4">{t("queue.qrCode")}</h2>
{qrQuery.data ? (
<div className="text-center">
<img src={qrQuery.data.dataUrl} alt={t("queue.qrAlt")} className="w-44 h-44 mx-auto rounded-xl border border-slate-200 mb-3" />
<p className="text-slate-500 text-xs mb-3">
{t("queue.qrExpires")} : {qrQuery.data.qrTokenExpiresAt ? formatTime(qrQuery.data.qrTokenExpiresAt) : "—"}
</p>
<div className="flex gap-2">
<Button variant="outline" size="sm" className="flex-1" onClick={() => regenQr.mutate({ id: clinicId })} disabled={regenQr.isPending}>
{regenQr.isPending ? <Loader2 className="w-3 h-3 mr-1 animate-spin" /> : <RefreshCw className="w-3 h-3 mr-1" />} {t("queue.qrRenew")}
</Button>
<Button variant="outline" size="sm" className="flex-1" onClick={() => navigate(`/dashboard/poster/${clinicId}`)}>
<Printer className="w-3 h-3 mr-1" /> {t("queue.qrPoster")}
</Button>
</div>
</div>
) : (
<div className="flex items-center justify-center py-10">
<Loader2 className="w-6 h-6 text-emerald-500 animate-spin" />
</div>
)}
</div>
{/* Stats */}
<div className="glass-card rounded-2xl p-6">
<h2 className="text-xs font-bold text-slate-500 uppercase tracking-widest mb-4">{t("queue.statsTitle")}</h2>
<div className="space-y-3">
{[
{ label: t("queue.waiting"), value: waiting.length, icon: Users },
{ label: t("queue.called"), value: called.length, icon: Play },
{ label: t("queue.statsAvgConsult"), value: t("queue.statsAvgConsultValue", { minutes: clinic?.avgConsultationMinutes ?? 15 }), icon: Clock },
].map((s) => {
const Icon = s.icon;
return (
<div key={s.label} className="flex items-center justify-between">
<div className="flex items-center gap-2 text-slate-600 text-sm">
<Icon className="w-4 h-4" /> {s.label}
</div>
<span className="font-bold text-slate-900">{s.value}</span>
</div>
);
})}
</div>
</div>
</div>
{/* ─── 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 gap-3 flex-wrap">
<h2 className="font-bold">{t("queue.queueListTitle")}</h2>
<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 ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="w-6 h-6 text-emerald-500 animate-spin" />
</div>
) : queue.length === 0 ? (
<div className="text-center py-16 px-6">
<Sparkles className="w-12 h-12 text-emerald-300 mx-auto mb-4" />
<p className="text-slate-500 mb-2">{t("queue.noPatients")}</p>
{!clinic?.isQueueOpen && (
<p className="text-slate-400 text-sm">{t("queue.openToWelcome")}</p>
)}
</div>
) : (
<div className="divide-y divide-slate-100">
{queue.map((entry) => {
const draggable = entry.status === "waiting";
return (
<div
key={entry.id}
draggable={draggable}
onDragStart={() => draggable && setDragId(entry.id)}
onDragOver={(e) => { if (draggable) { e.preventDefault(); setDragOverId(entry.id); } }}
onDragLeave={() => setDragOverId((id) => (id === entry.id ? null : id))}
onDrop={() => draggable && handleDrop(entry.id)}
className={`flex items-center gap-3 p-4 transition-all ${
entry.status === "called"
? "bg-emerald-50/60"
: entry.status === "in_consultation"
? "bg-cyan-50/40"
: "hover:bg-emerald-50/30"
} ${dragOverId === entry.id ? "ring-2 ring-emerald-400" : ""} ${draggable ? "cursor-move" : ""}`}
>
{/* Ticket */}
<div
className={`w-12 h-12 rounded-xl flex items-center justify-center font-bold text-lg flex-shrink-0 ${
entry.status === "called"
? "bg-emerald-500 text-white shadow-md"
: entry.status === "in_consultation"
? "bg-cyan-500 text-white shadow-md"
: "bg-white border border-slate-200 text-slate-700"
}`}
>
{formatTicket(entry.ticketNumber)}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5 flex-wrap">
<span className="font-semibold text-sm truncate">
{entry.patientName ?? t("queue.patientFallback", { number: entry.ticketNumber })}
</span>
{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>
<span>·</span>
<span>~{entry.estimatedWaitMinutes ?? "?"} {t("queue.minShort")}</span>
<span>·</span>
<span>{formatTime(entry.joinedAt)}</span>
</div>
</div>
{/* Status */}
<StatusBadge status={entry.status} />
{/* Actions */}
<div className="flex items-center gap-1 flex-shrink-0">
{entry.status === "waiting" && (
<button
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")}
>
<Play className="w-4 h-4" />
</button>
)}
{(entry.status === "called" || entry.status === "in_consultation") && (
<button
onClick={() => markDone.mutate({ entryId: entry.id })}
className="w-8 h-8 rounded-lg flex items-center justify-center text-emerald-600 hover:bg-emerald-100"
title={t("queue.endConsultation")}
>
<CheckCircle2 className="w-4 h-4" />
</button>
)}
{(entry.status === "waiting" || entry.status === "called") && (
<button
onClick={() => markAbsent.mutate({ entryId: entry.id })}
className="w-8 h-8 rounded-lg flex items-center justify-center text-orange-600 hover:bg-orange-100"
title={t("queue.markAbsentTitle")}
>
<UserX className="w-4 h-4" />
</button>
)}
</div>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
</div>
);
}
function StatusBadge({ status }: { status: QueueEntryStatus }) {
const { t } = useTranslation();
const map: Record<QueueEntryStatus, { label: string; className: string }> = {
waiting: { label: t("queue.statusWaiting"), className: "badge-waiting" },
called: { label: t("queue.statusCalled"), className: "badge-called" },
in_consultation: { label: t("queue.statusInConsultation"), className: "badge-called" },
done: { label: t("queue.statusDone"), className: "badge-done" },
absent: { label: t("queue.statusAbsent"), className: "badge-absent" },
canceled: { label: t("queue.statusCanceled"), className: "badge-absent" },
};
const m = map[status];
return <span className={m.className}>{m.label}</span>;
}