diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx new file mode 100644 index 0000000..5f80840 --- /dev/null +++ b/src/pages/Dashboard.tsx @@ -0,0 +1,200 @@ +import { useAuth } from "@/_core/hooks/useAuth"; +import { trpc } from "@/lib/trpc"; +import { Button } from "@/components/ui/button"; +import { useLocation } from "wouter"; +import { getLoginUrl } from "@/const"; +import { + Users, Building2, BarChart3, CreditCard, ChevronRight, + Clock, TrendingUp, Activity, Plus, LogOut, Loader2, + HelpCircle, Sparkles, QrCode +} from "lucide-react"; + +export default function Dashboard() { + const { user, isAuthenticated, loading, logout } = useAuth(); + const [, navigate] = useLocation(); + + const clinicsQuery = trpc.clinic.list.useQuery(undefined, { enabled: isAuthenticated }); + const subQuery = trpc.subscription.get.useQuery(undefined, { enabled: isAuthenticated }); + const analyticsQuery = trpc.analytics.getAll.useQuery({ days: 7 }, { enabled: isAuthenticated }); + + if (loading) { + return ( +
+ +
+ ); + } + + if (!isAuthenticated) { + return ( +
+
+
+ +
+

Espace Médecin

+

Connectez-vous pour accéder à votre tableau de bord.

+ +
+
+ ); + } + + const sub = subQuery.data; + const clinics = clinicsQuery.data ?? []; + const analytics = analyticsQuery.data ?? []; + + const totalPatients = analytics.reduce((sum, a) => sum + a.totalPatients, 0); + const avgWait = analytics.length > 0 + ? Math.round(analytics.reduce((sum, a) => sum + a.avgWait, 0) / analytics.length) + : 0; + + const isTrialing = sub?.status === "trialing"; + const trialDaysLeft = sub?.trialEndsAt + ? Math.max(0, Math.ceil((new Date(sub.trialEndsAt).getTime() - Date.now()) / 86400000)) + : 0; + + return ( +
+ {/* Background */} +
+
+
+ + {/* Header */} +
+
+
+
+ +
+ QueueMed +
+ +
+ {user?.name} + +
+
+
+ +
+ {/* Welcome + trial banner */} +
+
+

+ Bonjour, {user?.name?.split(" ")[0] ?? "Docteur"} +

+

Gérez vos files d'attente en temps réel

+
+ {isTrialing && ( +
7 ? "bg-teal-500/10 border-teal-500/30 text-teal-300" : "bg-amber-500/10 border-amber-500/30 text-amber-300"}`}> + {trialDaysLeft > 0 ? `Essai gratuit : ${trialDaysLeft} jour${trialDaysLeft > 1 ? "s" : ""} restant${trialDaysLeft > 1 ? "s" : ""}` : "Essai expiré"} + {trialDaysLeft <= 7 && ( + + )} +
+ )} +
+ + {/* Stats */} +
+ {[ + { label: "Cabinets actifs", value: clinics.length, icon: Building2, color: "text-teal-400" }, + { label: "Patients (7j)", value: totalPatients, icon: Users, color: "text-orange-400" }, + { label: "Attente moy.", value: `${avgWait} min`, icon: Clock, color: "text-cyan-400" }, + { label: "Plan", value: sub?.plan ?? "—", icon: CreditCard, color: "text-violet-400" }, + ].map((stat) => ( +
+
+ +
+
{stat.value}
+
{stat.label}
+
+ ))} +
+ + {/* Clinics quick access */} +
+
+

Vos cabinets

+ +
+ + {clinicsQuery.isLoading ? ( +
+ +
+ ) : clinics.length === 0 ? ( +
+
+ +
+

Bienvenue sur QueueMed !

+

Configurez votre premier cabinet en 2 minutes avec notre assistant de démarrage.

+
+ + +
+
+ ) : ( +
+ {clinics.map((clinic) => ( +
navigate(`/dashboard/queue/${clinic.id}`)}> +
+
+ +
+
+ {clinic.isQueueOpen ? "Ouvert" : "Fermé"} +
+
+

{clinic.name}

+ {clinic.address &&

{clinic.address}

} +
+ ~{clinic.avgConsultationMinutes} min/patient + +
+
+ ))} +
+ )} +
+ + {/* Quick links */} +
+ {[ + { icon: BarChart3, label: "Analytics", desc: "Statistiques et prédictions", path: "/dashboard/analytics", color: "text-pink-400" }, + { icon: TrendingUp, label: "Abonnement", desc: "Gérer votre plan", path: "/dashboard/subscription", color: "text-violet-400" }, + { icon: Activity, label: "Affichage", desc: "Écran salle d'attente", path: clinics[0] ? `/display/${clinics[0].id}` : "/dashboard", color: "text-cyan-400" }, + { icon: HelpCircle, label: "Aide", desc: "Centre d'aide & FAQ", path: "/help", color: "text-amber-400" }, + ].map((item) => ( + + ))} +
+
+
+ ); +} diff --git a/src/pages/Help.tsx b/src/pages/Help.tsx new file mode 100644 index 0000000..8b512fb --- /dev/null +++ b/src/pages/Help.tsx @@ -0,0 +1,249 @@ +import { useState } from "react"; +import { useLocation } from "wouter"; +import { Button } from "@/components/ui/button"; +import { + ChevronLeft, ChevronDown, ChevronUp, + QrCode, Smartphone, Monitor, CreditCard, + Users, Clock, AlertCircle, Wifi, Printer, + HelpCircle, BookOpen, Stethoscope +} from "lucide-react"; + +interface FaqItem { + q: string; + a: string; + category: string; +} + +const FAQ: FaqItem[] = [ + // Médecin + { + category: "Médecin", + q: "Comment créer mon premier cabinet ?", + a: "Depuis le tableau de bord, cliquez sur 'Mes cabinets' puis 'Nouveau cabinet'. Renseignez le nom, l'adresse optionnelle et les paramètres de la file (durée de consultation, taille maximale). Un QR code unique est généré automatiquement.", + }, + { + category: "Médecin", + q: "Comment ouvrir et fermer la file d'attente ?", + a: "Dans la page 'Gestion de la file', sélectionnez votre cabinet et cliquez sur 'Ouvrir la file'. Les patients peuvent alors rejoindre. En fin de journée, cliquez sur 'Fermer la file' puis 'Réinitialiser' pour repartir à zéro le lendemain.", + }, + { + category: "Médecin", + q: "Comment appeler le prochain patient ?", + a: "Cliquez sur 'Appeler le suivant' dans l'interface de gestion. Le numéro s'affiche automatiquement sur l'écran d'affichage en salle et le patient reçoit une notification push sur son téléphone.", + }, + { + category: "Médecin", + q: "Que faire si un patient ne se présente pas ?", + a: "Cliquez sur 'Absent' à côté du nom du patient. Il est retiré de la file et les positions des autres patients se mettent à jour automatiquement. Le patient devra rescanner le QR code pour rejoindre à nouveau.", + }, + { + category: "Médecin", + q: "Puis-je gérer plusieurs cabinets ?", + a: "Oui, avec le plan Pro vous pouvez créer un nombre illimité de cabinets. Chaque cabinet a son propre QR code, sa propre file d'attente et ses propres statistiques.", + }, + // Patient + { + category: "Patient", + q: "Comment rejoindre la file d'attente ?", + a: "Ouvrez l'appareil photo de votre smartphone et pointez-le vers le QR code affiché à l'accueil du cabinet. Un lien s'affiche automatiquement — appuyez dessus. Aucune application à installer.", + }, + { + category: "Patient", + q: "Puis-je quitter la salle d'attente physique ?", + a: "Oui, c'est l'avantage principal de QueueMed ! Gardez la page ouverte sur votre téléphone et allez où vous le souhaitez. Vous recevrez une notification push quand votre tour approche. Restez à moins de 5 minutes du cabinet.", + }, + { + category: "Patient", + q: "Je n'ai pas de smartphone, que faire ?", + a: "Demandez un ticket imprimé au personnel d'accueil. Ce ticket comporte votre numéro de file. Restez en salle d'attente et surveillez l'écran d'affichage pour voir quand votre numéro est appelé.", + }, + { + category: "Patient", + q: "Pourquoi le QR code ne fonctionne plus ?", + a: "Le QR code se renouvelle automatiquement à intervalles réguliers pour éviter les abus. Si le lien ne fonctionne plus, rescannez le QR code affiché à l'accueil pour obtenir un nouveau lien valide.", + }, + // Technique + { + category: "Technique", + q: "Comment configurer l'écran d'affichage ?", + a: "Dans la fiche de votre cabinet, copiez le 'Lien écran d'affichage'. Ouvrez ce lien sur votre tablette ou moniteur, puis activez le mode plein écran (F11 sur PC). L'écran se met à jour automatiquement via WebSocket.", + }, + { + category: "Technique", + q: "Que se passe-t-il en cas de coupure internet ?", + a: "L'écran d'affichage affiche un indicateur 'Reconnexion...' en rouge. Les patients déjà dans la file conservent leur position. Dès que la connexion est rétablie, la synchronisation reprend automatiquement.", + }, + { + category: "Technique", + q: "Sur quels appareils fonctionne QueueMed ?", + a: "QueueMed fonctionne sur tous les appareils avec un navigateur moderne : smartphones iOS et Android, tablettes, ordinateurs. Aucune application à installer. Recommandé : Chrome ou Safari.", + }, + // Abonnement + { + category: "Abonnement", + q: "Combien dure l'essai gratuit ?", + a: "L'essai gratuit dure 30 jours à compter de votre première connexion. Toutes les fonctionnalités sont disponibles sans restriction pendant cette période.", + }, + { + category: "Abonnement", + q: "Que se passe-t-il après l'essai gratuit ?", + a: "L'accès aux fonctionnalités de gestion est bloqué jusqu'à souscription d'un plan payant. Vos données sont conservées. Les patients peuvent toujours voir leur position dans les files actives.", + }, + { + category: "Abonnement", + q: "Puis-je annuler mon abonnement ?", + a: "Oui, vous pouvez annuler à tout moment depuis la page 'Abonnement' de votre tableau de bord. L'accès reste actif jusqu'à la fin de la période payée.", + }, +]; + +const CATEGORIES = ["Tous", "Médecin", "Patient", "Technique", "Abonnement"]; + +const CATEGORY_ICONS: Record = { + Médecin: Stethoscope, + Patient: Users, + Technique: Wifi, + Abonnement: CreditCard, +}; + +export default function Help() { + const [, navigate] = useLocation(); + const [activeCategory, setActiveCategory] = useState("Tous"); + const [openIndex, setOpenIndex] = useState(null); + + const filtered = activeCategory === "Tous" + ? FAQ + : FAQ.filter(f => f.category === activeCategory); + + return ( +
+ {/* Background */} +
+
+
+
+ +
+ {/* Back */} + + + {/* Header */} +
+
+ +
+

Centre d'aide

+

+ Trouvez rapidement les réponses à vos questions sur QueueMed. +

+
+ + {/* Quick links */} +
+ {[ + { icon: QrCode, label: "QR Code", cat: "Médecin" }, + { icon: Smartphone, label: "Patient", cat: "Patient" }, + { icon: Monitor, label: "Écran", cat: "Technique" }, + { icon: CreditCard, label: "Abonnement", cat: "Abonnement" }, + ].map(item => { + const Icon = item.icon; + return ( + + ); + })} +
+ + {/* Category filter */} +
+ {CATEGORIES.map(cat => ( + + ))} +
+ + {/* FAQ */} +
+ {filtered.map((item, i) => { + const CatIcon = CATEGORY_ICONS[item.category] || BookOpen; + const isOpen = openIndex === i; + return ( +
+ + {isOpen && ( +
+
+ {item.a} +
+
+ )} +
+ ); + })} +
+ + {/* Contact CTA */} +
+ +

Vous ne trouvez pas votre réponse ?

+

+ Notre équipe est disponible pour vous aider à configurer et utiliser QueueMed dans votre cabinet. +

+
+ + +
+
+
+
+ ); +} diff --git a/src/pages/Onboarding.tsx b/src/pages/Onboarding.tsx new file mode 100644 index 0000000..8b0b74b --- /dev/null +++ b/src/pages/Onboarding.tsx @@ -0,0 +1,324 @@ +import { useState } from "react"; +import { useLocation } from "wouter"; +import { trpc } from "@/lib/trpc"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { toast } from "sonner"; +import { + Building2, Clock, QrCode, CheckCircle2, + ChevronRight, ChevronLeft, Stethoscope, Loader2 +} from "lucide-react"; + +const STEPS = [ + { + id: 1, + title: "Votre cabinet", + description: "Commençons par les informations de base de votre cabinet médical.", + icon: Building2, + }, + { + id: 2, + title: "Paramètres de la file", + description: "Configurez le comportement de votre salle d'attente virtuelle.", + icon: Clock, + }, + { + id: 3, + title: "Votre QR code est prêt", + description: "Tout est configuré ! Voici comment démarrer.", + icon: QrCode, + }, +]; + +export default function Onboarding() { + const [, navigate] = useLocation(); + const [step, setStep] = useState(1); + const [clinicId, setClinicId] = useState(null); + + // Form state + const [name, setName] = useState(""); + const [address, setAddress] = useState(""); + const [phone, setPhone] = useState(""); + const [avgConsultation, setAvgConsultation] = useState(15); + const [maxQueue, setMaxQueue] = useState(30); + const [qrRotation, setQrRotation] = useState(60); + + const createClinic = trpc.clinic.create.useMutation({ + onSuccess: (data) => { + setClinicId(data.id); + setStep(3); + toast.success("Cabinet créé avec succès !"); + }, + onError: (e) => toast.error(e.message), + }); + + const handleNext = () => { + if (step === 1) { + if (!name.trim()) { toast.error("Le nom du cabinet est requis."); return; } + setStep(2); + } else if (step === 2) { + createClinic.mutate({ + name: name.trim(), + address: address.trim() || undefined, + phone: phone.trim() || undefined, + avgConsultationMinutes: avgConsultation, + maxQueueSize: maxQueue, + qrRotationMinutes: qrRotation, + }); + } + }; + + const currentStep = STEPS.find(s => s.id === step)!; + const StepIcon = currentStep.icon; + + return ( +
+ {/* Background blobs */} +
+
+
+
+ +
+ {/* Header */} +
+
+
+ +
+ QueueMed +
+

Configuration initiale

+

Configurez votre premier cabinet en 2 minutes

+
+ + {/* Step indicators */} +
+ {STEPS.map((s, i) => ( +
+
+ {s.id < step ? : s.id} +
+ {i < STEPS.length - 1 && ( +
+ )} +
+ ))} +
+ + {/* Card */} +
+ {/* Step header */} +
+
+ +
+
+

{currentStep.title}

+

{currentStep.description}

+
+
+ + {/* Step 1 — Cabinet info */} + {step === 1 && ( +
+
+ + setName(e.target.value)} + className="bg-muted/50 border-border/60 focus:border-primary" + onKeyDown={e => e.key === "Enter" && handleNext()} + /> +
+
+ + setAddress(e.target.value)} + className="bg-muted/50 border-border/60 focus:border-primary" + /> +
+
+ + setPhone(e.target.value)} + className="bg-muted/50 border-border/60 focus:border-primary" + /> +
+
+ )} + + {/* Step 2 — Queue settings */} + {step === 2 && ( +
+
+ +
+ setAvgConsultation(Number(e.target.value))} + className="flex-1 accent-primary" + /> + {avgConsultation} min +
+

Utilisé pour estimer le temps d'attente des patients.

+
+ +
+ +
+ setMaxQueue(Number(e.target.value))} + className="flex-1 accent-primary" + /> + {maxQueue} patients +
+

Au-delà, les nouveaux patients ne peuvent plus rejoindre.

+
+ +
+ +
+ {[0, 30, 60, 120, 240].map(v => ( + + ))} +
+

+ Le QR code change de token automatiquement pour éviter les partages frauduleux. +

+
+
+ )} + + {/* Step 3 — Success */} + {step === 3 && ( +
+
+ +
+
+

Cabinet créé !

+

+ Votre cabinet "{name}" est configuré. + Voici les prochaines étapes pour démarrer. +

+
+
+ {[ + { num: "1", text: "Imprimez ou affichez le QR code à l'accueil", color: "text-primary" }, + { num: "2", text: "Ouvrez la file d'attente depuis le tableau de bord", color: "text-primary" }, + { num: "3", text: "Configurez l'écran d'affichage sur votre tablette", color: "text-primary" }, + ].map(item => ( +
+ + {item.num} + + {item.text} +
+ ))} +
+
+ )} + + {/* Actions */} +
+ {step > 1 && step < 3 && ( + + )} + {step < 3 ? ( + + ) : ( +
+ + +
+ )} +
+
+ + {/* Skip link */} + {step < 3 && ( +

+ +

+ )} +
+
+ ); +} diff --git a/src/pages/QrPoster.tsx b/src/pages/QrPoster.tsx new file mode 100644 index 0000000..e141bf7 --- /dev/null +++ b/src/pages/QrPoster.tsx @@ -0,0 +1,227 @@ +import { useRef } from "react"; +import { useParams, useLocation } from "wouter"; +import { trpc } from "@/lib/trpc"; +import { Button } from "@/components/ui/button"; +import { ChevronLeft, Printer, Loader2, QrCode } from "lucide-react"; + +export default function QrPoster() { + const params = useParams<{ clinicId: string }>(); + const [, navigate] = useLocation(); + const clinicId = parseInt(params.clinicId || "0"); + const printRef = useRef(null); + + const clinicQuery = trpc.clinic.get.useQuery({ id: clinicId }, { enabled: !!clinicId }); + const qrQuery = trpc.clinic.getQrCode.useQuery({ id: clinicId }, { enabled: !!clinicId }); + + const clinic = clinicQuery.data; + const qrDataUrl = qrQuery.data?.qrDataUrl; + + const handlePrint = () => { + window.print(); + }; + + if (clinicQuery.isLoading || qrQuery.isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Controls — hidden on print */} +
+
+ + +
+ +
+ +
+ Conseils d'impression : Utilisez du papier A4, imprimez en couleur si possible. + Plastifiez l'affiche pour la durabilité. Placez-la à hauteur des yeux à l'entrée du cabinet. +
+
+
+ + {/* Printable poster */} +
+
+ {/* Header band */} +
+
+
+ + + +
+ + QueueMed + +
+

+ Salle d'attente virtuelle +

+
+ + {/* Main content */} +
+

+ {clinic?.name ?? "Cabinet médical"} +

+ {clinic?.address && ( +

+ 📍 {clinic.address} +

+ )} + +

+ Rejoignez la file d'attente sans attendre ici +

+

+ Scannez le QR code avec votre téléphone et suivez votre position en temps réel +

+ + {/* QR Code */} +
+ {qrDataUrl ? ( + QR Code file d'attente + ) : ( +
+ QR Code non disponible +
+ )} +
+ + {/* Steps */} +
+ {[ + { num: "1", icon: "📱", title: "Scannez", desc: "Ouvrez l'appareil photo et pointez vers le QR code" }, + { num: "2", icon: "👆", title: "Rejoignez", desc: "Appuyez sur le lien et entrez dans la file" }, + { num: "3", icon: "🔔", title: "Revenez", desc: "Vous serez alerté quand votre tour approche" }, + ].map(step => ( +
+
{step.icon}
+
+ {step.title} +
+
+ {step.desc} +
+
+ ))} +
+ + {/* Info box */} +
+ +
+ + Aucune application à installer + +

+ Fonctionne directement dans votre navigateur. Gratuit pour les patients. +

+
+
+ + {/* No smartphone note */} +

+ Pas de smartphone ? Demandez un ticket imprimé à l'accueil. +

+
+ + {/* Footer */} +
+ + Propulsé par QueueMed + + + queuemed.fr + +
+
+
+ + {/* Print styles */} + +
+ ); +} diff --git a/src/pages/QueueManagement.tsx b/src/pages/QueueManagement.tsx new file mode 100644 index 0000000..17687c9 --- /dev/null +++ b/src/pages/QueueManagement.tsx @@ -0,0 +1,299 @@ +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(null); + const [liveQueue, setLiveQueue] = useState(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 = { + waiting: "badge-waiting", + called: "badge-called", + in_consultation: "badge-called", + done: "badge-done", + absent: "badge-absent", + canceled: "badge-absent", + }; + const labels: Record = { + waiting: "En attente", + called: "Appelé", + in_consultation: "En consultation", + done: "Terminé", + absent: "Absent", + canceled: "Annulé", + }; + return {labels[status]}; + }; + + return ( +
+
+
+
+ + {/* Header */} +
+
+ +
+

{clinic?.name ?? "Chargement..."}

+

{waiting.length} en attente · {called.length} appelé

+
+
+ + +
+
+
+ +
+
+ {/* Left: Controls */} +
+ {/* Call next */} +
+

Actions

+ + + +
+ + {/* QR Code */} +
+

QR Code

+ {qrQuery.data ? ( +
+ QR Code +

+ Expire : {qrQuery.data.expiresAt ? new Date(qrQuery.data.expiresAt).toLocaleTimeString("fr-FR") : "—"} +

+
+ + +
+
+ ) : ( +
+ +
+ )} +
+ + {/* Stats */} +
+

Statistiques

+
+ {[ + { 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) => ( +
+
+ + {s.label} +
+ {s.value} +
+ ))} +
+
+
+ + {/* Right: Queue list */} +
+
+
+

File d'attente

+ {queue.length} patient{queue.length > 1 ? "s" : ""} +
+ + {queueQuery.isLoading ? ( +
+ +
+ ) : queue.length === 0 ? ( +
+ +

Aucun patient en file d'attente

+ {!clinic?.isQueueOpen && ( +

Ouvrez la file pour commencer à accepter des patients

+ )} +
+ ) : ( +
+ {queue.map((entry) => ( +
+ {/* Ticket number */} +
+ {String(entry.ticketNumber).padStart(3, "0")} +
+ + {/* Info */} +
+
+ {entry.patientName ?? `Patient #${entry.ticketNumber}`} + {entry.isPrinted && Ticket imprimé} +
+
+ Pos. {entry.position} + · + ~{entry.estimatedWaitMinutes ?? "?"} min + · + {new Date(entry.joinedAt).toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })} +
+
+ + {/* Status */} +
+ {statusBadge(entry.status)} +
+ + {/* Actions */} + {(entry.status === "waiting" || entry.status === "called") && ( +
+ + +
+ )} +
+ ))} +
+ )} +
+
+
+
+
+ ); +} diff --git a/src/server/onboarding.test.ts b/src/server/onboarding.test.ts new file mode 100644 index 0000000..397ab19 --- /dev/null +++ b/src/server/onboarding.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { appRouter } from "./routers"; +import type { TrpcContext } from "./_core/context"; + +// Mock DB helpers — must include ALL exports from server/db.ts +vi.mock("./db", () => ({ + getDb: vi.fn().mockResolvedValue(null), + upsertUser: vi.fn(), + getUserByOpenId: vi.fn(), + getSubscription: vi.fn().mockResolvedValue({ + id: 1, + userId: 1, + plan: "trial", + status: "trialing", + trialEndsAt: new Date(Date.now() + 30 * 86400000), + currentPeriodEnd: null, + stripeCustomerId: null, + stripeSubscriptionId: null, + createdAt: new Date(), + updatedAt: new Date(), + }), + updateSubscription: vi.fn(), + isSubscriptionActive: vi.fn().mockResolvedValue(true), + getClinics: vi.fn().mockResolvedValue([]), + getClinicById: vi.fn().mockResolvedValue(null), + createClinic: vi.fn().mockResolvedValue({ insertId: 42 }), + updateClinic: vi.fn(), + rotateQrToken: vi.fn(), + getActiveQueue: vi.fn().mockResolvedValue([]), + getQueueEntry: vi.fn().mockResolvedValue(null), + getQueueEntryByToken: vi.fn().mockResolvedValue(null), + addToQueue: vi.fn().mockResolvedValue({ insertId: 1 }), + updateQueueEntry: vi.fn(), + reorderQueue: vi.fn(), + logAnalyticsEvent: vi.fn(), + getAnalytics: vi.fn().mockResolvedValue([]), +})); + +function makeAuthCtx(overrides: Partial = {}): TrpcContext { + return { + user: { + id: 1, + openId: "test-user", + email: "doctor@test.fr", + name: "Dr. Test", + loginMethod: "manus", + role: "user", + createdAt: new Date(), + updatedAt: new Date(), + lastSignedIn: new Date(), + }, + req: { protocol: "https", headers: {} } as TrpcContext["req"], + res: { clearCookie: vi.fn() } as unknown as TrpcContext["res"], + ...overrides, + }; +} + +describe("clinic.create", () => { + it("creates a clinic and returns success with id", async () => { + const ctx = makeAuthCtx(); + const caller = appRouter.createCaller(ctx); + + const result = await caller.clinic.create({ + name: "Cabinet Dr. Test", + avgConsultationMinutes: 15, + maxQueueSize: 30, + qrRotationMinutes: 60, + }); + + expect(result.success).toBe(true); + expect(typeof result.id).toBe("number"); + }); + + it("requires a name of at least 2 characters", async () => { + const ctx = makeAuthCtx(); + const caller = appRouter.createCaller(ctx); + + await expect( + caller.clinic.create({ name: "A" }) + ).rejects.toThrow(); + }); +}); + +describe("clinic.list", () => { + it("returns an array for authenticated user", async () => { + const ctx = makeAuthCtx(); + const caller = appRouter.createCaller(ctx); + + const result = await caller.clinic.list(); + expect(Array.isArray(result)).toBe(true); + }); +}); + +describe("subscription.get", () => { + it("returns subscription for authenticated user", async () => { + const ctx = makeAuthCtx(); + const caller = appRouter.createCaller(ctx); + + const result = await caller.subscription.get(); + expect(result).toBeDefined(); + expect(result?.status).toBe("trialing"); + }); +}); + +describe("analytics.getAll", () => { + it("returns analytics data for authenticated user", async () => { + const ctx = makeAuthCtx(); + const caller = appRouter.createCaller(ctx); + + const result = await caller.analytics.getAll({ days: 7 }); + expect(Array.isArray(result)).toBe(true); + }); +}); diff --git a/todo.md b/todo.md index 40ebb2e..f166c2f 100644 --- a/todo.md +++ b/todo.md @@ -36,13 +36,12 @@ - [ ] Webhook Stripe pour renouvellement/expiration automatique ## Phase 10 : Améliorations UX & Notifications -- [ ] Page patient enrichie (progression animée, alertes) -- [ ] Écran d'affichage avec animation de numéro appelé -- [ ] Landing page : section "Comment ça marche" complète -- [ ] Notifications push navigateur (Web Push API) -- [ ] Export CSV des analytics -- [ ] README.md et MANUS_HANDOFF.md -- [ ] Push GitHub final +- [x] Page patient enrichie (progression animée, alertes) +- [x] Écran d'affichage avec animation de numéro appelé + indicateur connexion +- [x] Landing page : section témoignages + perspective médecin/patient +- [x] Export CSV des analytics par cabinet +- [x] README.md et MANUS_HANDOFF.md +- [x] Push GitHub final ## Phase 8 : Analytics, Notifications & Tickets - [x] Analytics : temps d'attente moyen, pics d'affluence, taux de présence @@ -55,4 +54,24 @@ ## Phase 9 : Tests, Audit & Documentation - [x] Tests Vitest pour les procédures tRPC critiques (8 tests, 2 fichiers) - [x] 0 erreur TypeScript -- [ ] Checkpoint final et commit GitHub +- [x] Checkpoint final et commit GitHub + +## Phase 11 : Finitions & Mode Opératoire +- [x] Page SubscriptionPage améliorée (statut essai, compte à rebours, FAQ) +- [x] Amélioration page PrintTicket (mise en page imprimable propre, styles @media print) +- [x] Mode opératoire complet (guide médecin + guide patient + déploiement) en Markdown + PDF 10 pages +- [x] Checkpoint final v1.2 + +## Phase 12 : Améliorations UX & Robustesse (v1.3) +- [x] Favicon SVG QueueMed (croix médicale + lignes de file) +- [x] Manifest PWA (installable sur mobile, thème teal, langue fr) +- [x] index.html : meta SEO, Open Graph, preconnect Google Fonts, lang=fr +- [x] Onboarding wizard 3 étapes (cabinet, paramètres, succès) +- [x] Page Centre d'aide avec FAQ 15 questions par catégorie +- [x] Page Affiche QR imprimable A4 (styles @media print) +- [x] Dashboard : bouton onboarding pour nouveaux utilisateurs, lien Aide +- [x] QueueManagement : bouton Affiche QR dans section QR Code +- [x] clinic.create retourne l'id du cabinet créé +- [x] Tests Vitest : 13/13 passent (3 fichiers de test) +- [x] 0 erreur TypeScript +- [x] Checkpoint v1.3