511 lines
24 KiB
TypeScript
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>;
|
|
}
|