299 lines
14 KiB
TypeScript
299 lines
14 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import { useParams, useLocation } from "wouter";
|
|
import { trpc } from "@/lib/trpc";
|
|
import { Button } from "@/components/ui/button";
|
|
import { io, Socket } from "socket.io-client";
|
|
import {
|
|
ChevronLeft, Play, UserX, Trash2, QrCode, Monitor,
|
|
Users, Clock, Printer, RefreshCw, Loader2, Power, PowerOff
|
|
} from "lucide-react";
|
|
import { toast } from "sonner";
|
|
|
|
type EntryStatus = "waiting" | "called" | "in_consultation" | "done" | "absent" | "canceled";
|
|
|
|
interface QueueEntry {
|
|
id: number;
|
|
ticketNumber: number;
|
|
patientName: string | null;
|
|
status: EntryStatus;
|
|
position: number;
|
|
joinedAt: Date;
|
|
estimatedWaitMinutes: number | null;
|
|
isPrinted: boolean;
|
|
}
|
|
|
|
export default function QueueManagement() {
|
|
const params = useParams<{ clinicId: string }>();
|
|
const [, navigate] = useLocation();
|
|
const clinicId = parseInt(params.clinicId || "0");
|
|
const socketRef = useRef<Socket | null>(null);
|
|
const [liveQueue, setLiveQueue] = useState<QueueEntry[] | null>(null);
|
|
|
|
const clinicQuery = trpc.clinic.get.useQuery({ id: clinicId }, { enabled: !!clinicId });
|
|
const queueQuery = trpc.queue.getQueue.useQuery({ clinicId }, { enabled: !!clinicId, refetchInterval: 10000 });
|
|
const qrQuery = trpc.clinic.getQrCode.useQuery({ id: clinicId }, { enabled: !!clinicId });
|
|
|
|
const callNext = trpc.queue.callNext.useMutation({
|
|
onSuccess: (data) => { toast.success(`Ticket #${data.calledTicket} appelé !`); queueQuery.refetch(); },
|
|
onError: (e) => toast.error(e.message),
|
|
});
|
|
const markAbsent = trpc.queue.markAbsent.useMutation({
|
|
onSuccess: () => { toast.success("Patient marqué absent"); queueQuery.refetch(); },
|
|
onError: (e) => toast.error(e.message),
|
|
});
|
|
const removeEntry = trpc.queue.remove.useMutation({
|
|
onSuccess: () => { toast.success("Patient retiré de la file"); queueQuery.refetch(); },
|
|
onError: (e) => toast.error(e.message),
|
|
});
|
|
const toggleQueue = trpc.clinic.toggleQueue.useMutation({
|
|
onSuccess: () => { toast.success("Statut de la file mis à jour"); clinicQuery.refetch(); },
|
|
onError: (e) => toast.error(e.message),
|
|
});
|
|
const resetQueue = trpc.queue.reset.useMutation({
|
|
onSuccess: () => { toast.success("File réinitialisée"); queueQuery.refetch(); clinicQuery.refetch(); },
|
|
onError: (e) => toast.error(e.message),
|
|
});
|
|
const printTicket = trpc.queue.printTicket.useMutation({
|
|
onSuccess: (data) => {
|
|
toast.success(`Ticket #${data.ticketNumber} créé`);
|
|
window.open(data.printUrl, "_blank");
|
|
queueQuery.refetch();
|
|
},
|
|
onError: (e) => toast.error(e.message),
|
|
});
|
|
|
|
// WebSocket for live updates
|
|
useEffect(() => {
|
|
if (!clinicId) return undefined;
|
|
const socket = io("/", { path: "/api/socket.io", transports: ["websocket", "polling"] });
|
|
socketRef.current = socket;
|
|
socket.emit("doctor:join", { clinicId });
|
|
socket.on("queue:update", (data: { waiting: QueueEntry[] }) => {
|
|
if (data.waiting) setLiveQueue(data.waiting);
|
|
});
|
|
return () => { socket.disconnect(); };
|
|
}, [clinicId]);
|
|
|
|
const queue = liveQueue ?? (queueQuery.data as QueueEntry[] | undefined) ?? [];
|
|
const clinic = clinicQuery.data;
|
|
const waiting = queue.filter((e) => e.status === "waiting");
|
|
const called = queue.filter((e) => e.status === "called");
|
|
|
|
const statusBadge = (status: EntryStatus) => {
|
|
const map: Record<EntryStatus, string> = {
|
|
waiting: "badge-waiting",
|
|
called: "badge-called",
|
|
in_consultation: "badge-called",
|
|
done: "badge-done",
|
|
absent: "badge-absent",
|
|
canceled: "badge-absent",
|
|
};
|
|
const labels: Record<EntryStatus, string> = {
|
|
waiting: "En attente",
|
|
called: "Appelé",
|
|
in_consultation: "En consultation",
|
|
done: "Terminé",
|
|
absent: "Absent",
|
|
canceled: "Annulé",
|
|
};
|
|
return <span className={map[status]}>{labels[status]}</span>;
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen">
|
|
<div className="absolute inset-0 pointer-events-none">
|
|
<div className="absolute top-0 left-0 w-full h-64 bg-gradient-to-b from-primary/5 to-transparent" />
|
|
</div>
|
|
|
|
{/* Header */}
|
|
<header className="relative z-10 border-b border-border/50 backdrop-blur-xl bg-background/60 sticky top-0">
|
|
<div className="container flex items-center justify-between h-16 gap-4">
|
|
<Button variant="ghost" size="sm" onClick={() => navigate("/dashboard")} className="text-muted-foreground flex-shrink-0">
|
|
<ChevronLeft className="w-4 h-4 mr-1" /> Retour
|
|
</Button>
|
|
<div className="text-center min-w-0">
|
|
<h1 className="font-display font-bold text-base gradient-text truncate">{clinic?.name ?? "Chargement..."}</h1>
|
|
<p className="text-muted-foreground text-xs">{waiting.length} en attente · {called.length} appelé</p>
|
|
</div>
|
|
<div className="flex items-center gap-2 flex-shrink-0">
|
|
<Button
|
|
variant="outline" size="sm"
|
|
onClick={() => window.open(`/display/${clinicId}`, "_blank")}
|
|
className="border-border/60 text-muted-foreground hover:text-foreground hidden sm:flex"
|
|
>
|
|
<Monitor className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
onClick={() => toggleQueue.mutate({ id: clinicId, isOpen: !clinic?.isQueueOpen })}
|
|
disabled={toggleQueue.isPending}
|
|
className={clinic?.isQueueOpen ? "bg-destructive/20 border border-destructive/40 text-destructive hover:bg-destructive/30" : "bg-primary text-primary-foreground hover:bg-primary/90 glow-teal"}
|
|
>
|
|
{clinic?.isQueueOpen ? <><PowerOff className="w-4 h-4 mr-1" /> Fermer</> : <><Power className="w-4 h-4 mr-1" /> Ouvrir</>}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<main className="relative z-10 container py-6">
|
|
<div className="grid lg:grid-cols-3 gap-6">
|
|
{/* Left: Controls */}
|
|
<div className="space-y-4">
|
|
{/* Call next */}
|
|
<div className="glass-card rounded-2xl p-6">
|
|
<h2 className="font-display font-bold text-sm text-muted-foreground uppercase tracking-widest mb-4">Actions</h2>
|
|
<Button
|
|
onClick={() => callNext.mutate({ clinicId })}
|
|
disabled={callNext.isPending || waiting.length === 0}
|
|
className="w-full bg-primary text-primary-foreground hover:bg-primary/90 glow-teal h-14 text-base font-semibold mb-3"
|
|
>
|
|
{callNext.isPending ? <Loader2 className="w-5 h-5 animate-spin mr-2" /> : <Play className="w-5 h-5 mr-2" />}
|
|
Appeler le suivant
|
|
</Button>
|
|
<Button
|
|
onClick={() => printTicket.mutate({ clinicId })}
|
|
disabled={printTicket.isPending || !clinic?.isQueueOpen}
|
|
variant="outline"
|
|
className="w-full border-border/60 text-muted-foreground hover:text-foreground mb-3"
|
|
>
|
|
<Printer className="w-4 h-4 mr-2" /> Imprimer un ticket
|
|
</Button>
|
|
<Button
|
|
onClick={() => { if (confirm("Réinitialiser toute la file ?")) resetQueue.mutate({ clinicId }); }}
|
|
disabled={resetQueue.isPending}
|
|
variant="outline"
|
|
className="w-full border-destructive/30 text-destructive hover:bg-destructive/10"
|
|
>
|
|
<RefreshCw className="w-4 h-4 mr-2" /> Réinitialiser la file
|
|
</Button>
|
|
</div>
|
|
|
|
{/* QR Code */}
|
|
<div className="glass-card rounded-2xl p-6">
|
|
<h2 className="font-display font-bold text-sm text-muted-foreground uppercase tracking-widest mb-4">QR Code</h2>
|
|
{qrQuery.data ? (
|
|
<div className="text-center">
|
|
<img src={qrQuery.data.qrDataUrl} alt="QR Code" className="w-40 h-40 mx-auto rounded-xl mb-3" />
|
|
<p className="text-muted-foreground text-xs mb-3">
|
|
Expire : {qrQuery.data.expiresAt ? new Date(qrQuery.data.expiresAt).toLocaleTimeString("fr-FR") : "—"}
|
|
</p>
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" size="sm" onClick={() => qrQuery.refetch()} className="flex-1 border-border/60 text-muted-foreground">
|
|
<RefreshCw className="w-3 h-3 mr-2" /> Renouveler
|
|
</Button>
|
|
<Button variant="outline" size="sm" onClick={() => navigate(`/dashboard/poster/${clinicId}`)} className="flex-1 border-border/60 text-muted-foreground">
|
|
<Printer className="w-3 h-3 mr-2" /> Affiche
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center justify-center py-8">
|
|
<Loader2 className="w-6 h-6 text-primary animate-spin" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Stats */}
|
|
<div className="glass-card rounded-2xl p-6">
|
|
<h2 className="font-display font-bold text-sm text-muted-foreground uppercase tracking-widest mb-4">Statistiques</h2>
|
|
<div className="space-y-3">
|
|
{[
|
|
{ label: "En attente", value: waiting.length, icon: Users },
|
|
{ label: "Appelé", value: called.length, icon: Play },
|
|
{ label: "Attente moy.", value: `~${clinic?.avgConsultationMinutes ?? 15} min`, icon: Clock },
|
|
].map((s) => (
|
|
<div key={s.label} className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2 text-muted-foreground text-sm">
|
|
<s.icon className="w-4 h-4" />
|
|
{s.label}
|
|
</div>
|
|
<span className="font-display font-bold text-foreground">{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-border/50 flex items-center justify-between">
|
|
<h2 className="font-display font-bold">File d'attente</h2>
|
|
<span className="text-muted-foreground text-sm">{queue.length} patient{queue.length > 1 ? "s" : ""}</span>
|
|
</div>
|
|
|
|
{queueQuery.isLoading ? (
|
|
<div className="flex items-center justify-center py-16">
|
|
<Loader2 className="w-6 h-6 text-primary animate-spin" />
|
|
</div>
|
|
) : queue.length === 0 ? (
|
|
<div className="text-center py-16">
|
|
<Users className="w-12 h-12 text-muted-foreground/30 mx-auto mb-4" />
|
|
<p className="text-muted-foreground">Aucun patient en file d'attente</p>
|
|
{!clinic?.isQueueOpen && (
|
|
<p className="text-muted-foreground text-sm mt-2">Ouvrez la file pour commencer à accepter des patients</p>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="divide-y divide-border/50">
|
|
{queue.map((entry) => (
|
|
<div key={entry.id} className={`flex items-center gap-4 p-4 transition-all ${entry.status === "called" ? "bg-teal-500/5" : "hover:bg-muted/20"}`}>
|
|
{/* Ticket number */}
|
|
<div className={`w-12 h-12 rounded-xl flex items-center justify-center font-display font-bold text-lg flex-shrink-0 ${entry.status === "called" ? "bg-primary/20 border border-primary/40 text-primary" : "bg-muted border border-border text-foreground"}`}>
|
|
{String(entry.ticketNumber).padStart(3, "0")}
|
|
</div>
|
|
|
|
{/* Info */}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<span className="font-medium text-sm">{entry.patientName ?? `Patient #${entry.ticketNumber}`}</span>
|
|
{entry.isPrinted && <span className="text-xs text-muted-foreground bg-muted rounded px-1.5 py-0.5">Ticket imprimé</span>}
|
|
</div>
|
|
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
|
<span>Pos. {entry.position}</span>
|
|
<span>·</span>
|
|
<span>~{entry.estimatedWaitMinutes ?? "?"} min</span>
|
|
<span>·</span>
|
|
<span>{new Date(entry.joinedAt).toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Status */}
|
|
<div className="flex-shrink-0">
|
|
{statusBadge(entry.status)}
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
{(entry.status === "waiting" || entry.status === "called") && (
|
|
<div className="flex items-center gap-1 flex-shrink-0">
|
|
<Button
|
|
variant="ghost" size="sm"
|
|
onClick={() => markAbsent.mutate({ entryId: entry.id, clinicId })}
|
|
disabled={markAbsent.isPending}
|
|
className="text-amber-400 hover:text-amber-300 hover:bg-amber-500/10 w-8 h-8 p-0"
|
|
title="Marquer absent"
|
|
>
|
|
<UserX className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost" size="sm"
|
|
onClick={() => removeEntry.mutate({ entryId: entry.id, clinicId })}
|
|
disabled={removeEntry.isPending}
|
|
className="text-destructive hover:text-destructive hover:bg-destructive/10 w-8 h-8 p-0"
|
|
title="Retirer de la file"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|