queue-med/src_ref/pages/QueueManagement.tsx

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>
);
}