feat(v1.3): onboarding wizard, help center, QR poster A4, PWA manifest, 13 tests passing
This commit is contained in:
parent
2f966b1300
commit
3a30af33ef
7 changed files with 1439 additions and 8 deletions
200
src/pages/Dashboard.tsx
Normal file
200
src/pages/Dashboard.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 text-primary animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<div className="glass-card rounded-3xl p-10 text-center max-w-sm w-full">
|
||||
<div className="w-16 h-16 rounded-2xl bg-primary/20 border border-primary/40 flex items-center justify-center mx-auto mb-6 glow-teal">
|
||||
<Users className="w-8 h-8 text-primary" />
|
||||
</div>
|
||||
<h1 className="font-display text-2xl font-bold mb-3">Espace Médecin</h1>
|
||||
<p className="text-muted-foreground text-sm mb-8">Connectez-vous pour accéder à votre tableau de bord.</p>
|
||||
<Button onClick={() => window.location.href = getLoginUrl()} className="w-full bg-primary text-primary-foreground hover:bg-primary/90 glow-teal h-12 font-semibold">
|
||||
Se connecter
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="min-h-screen">
|
||||
{/* Background */}
|
||||
<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">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-primary/20 border border-primary/40 flex items-center justify-center">
|
||||
<Users className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<span className="font-display font-bold text-lg gradient-text">QueueMed</span>
|
||||
</div>
|
||||
<nav className="hidden md:flex items-center gap-6">
|
||||
<button onClick={() => navigate("/dashboard")} className="text-sm font-medium text-foreground">Accueil</button>
|
||||
<button onClick={() => navigate("/dashboard/clinics")} className="text-sm text-muted-foreground hover:text-foreground transition-colors">Cabinets</button>
|
||||
<button onClick={() => navigate("/dashboard/analytics")} className="text-sm text-muted-foreground hover:text-foreground transition-colors">Analytics</button>
|
||||
<button onClick={() => navigate("/dashboard/subscription")} className="text-sm text-muted-foreground hover:text-foreground transition-colors">Abonnement</button>
|
||||
</nav>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-muted-foreground hidden md:block">{user?.name}</span>
|
||||
<Button variant="ghost" size="sm" onClick={logout} className="text-muted-foreground hover:text-foreground">
|
||||
<LogOut className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="relative z-10 container py-8">
|
||||
{/* Welcome + trial banner */}
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between mb-8 gap-4">
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold mb-1">
|
||||
Bonjour, <span className="gradient-text">{user?.name?.split(" ")[0] ?? "Docteur"}</span>
|
||||
</h1>
|
||||
<p className="text-muted-foreground">Gérez vos files d'attente en temps réel</p>
|
||||
</div>
|
||||
{isTrialing && (
|
||||
<div className={`px-4 py-2 rounded-xl border text-sm font-medium ${trialDaysLeft > 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 && (
|
||||
<button onClick={() => navigate("/dashboard/subscription")} className="ml-2 underline">S'abonner</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
{[
|
||||
{ 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) => (
|
||||
<div key={stat.label} className="glass-card rounded-2xl p-5">
|
||||
<div className={`w-8 h-8 rounded-lg bg-card border border-border flex items-center justify-center mb-3 ${stat.color}`}>
|
||||
<stat.icon className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="font-display font-bold text-2xl text-foreground capitalize">{stat.value}</div>
|
||||
<div className="text-muted-foreground text-xs mt-1">{stat.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Clinics quick access */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="font-display font-bold text-xl">Vos cabinets</h2>
|
||||
<Button variant="outline" size="sm" onClick={() => navigate("/dashboard/clinics")} className="border-border/60 text-muted-foreground hover:text-foreground">
|
||||
<Plus className="w-4 h-4 mr-2" /> Gérer
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{clinicsQuery.isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 text-primary animate-spin" />
|
||||
</div>
|
||||
) : clinics.length === 0 ? (
|
||||
<div className="glass-card rounded-2xl p-8 text-center border-primary/20">
|
||||
<div className="w-16 h-16 rounded-2xl bg-primary/15 border border-primary/30 flex items-center justify-center mx-auto mb-4 glow-teal">
|
||||
<Sparkles className="w-8 h-8 text-primary" />
|
||||
</div>
|
||||
<h3 className="font-display font-bold text-xl mb-2">Bienvenue sur QueueMed !</h3>
|
||||
<p className="text-muted-foreground text-sm mb-6 max-w-sm mx-auto">Configurez votre premier cabinet en 2 minutes avec notre assistant de démarrage.</p>
|
||||
<div className="flex gap-3 justify-center flex-wrap">
|
||||
<Button onClick={() => navigate("/onboarding")} className="bg-primary text-primary-foreground hover:bg-primary/90 glow-teal">
|
||||
<Sparkles className="w-4 h-4 mr-2" /> Démarrer la configuration
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => navigate("/dashboard/clinics")} className="border-border/60">
|
||||
<Plus className="w-4 h-4 mr-2" /> Créer manuellement
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{clinics.map((clinic) => (
|
||||
<div key={clinic.id} className="glass-card rounded-2xl p-5 hover:border-primary/30 transition-all cursor-pointer group" onClick={() => navigate(`/dashboard/queue/${clinic.id}`)}>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center" style={{ backgroundColor: `${clinic.color}20`, border: `1px solid ${clinic.color}40` }}>
|
||||
<Building2 className="w-5 h-5" style={{ color: clinic.color ?? "#0d9488" }} />
|
||||
</div>
|
||||
<div className={`px-2 py-1 rounded-full text-xs font-medium ${clinic.isQueueOpen ? "badge-called" : "bg-muted text-muted-foreground border border-border"}`}>
|
||||
{clinic.isQueueOpen ? "Ouvert" : "Fermé"}
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="font-display font-semibold mb-1">{clinic.name}</h3>
|
||||
{clinic.address && <p className="text-muted-foreground text-xs mb-3 truncate">{clinic.address}</p>}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs">~{clinic.avgConsultationMinutes} min/patient</span>
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground group-hover:text-primary transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick links */}
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ 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) => (
|
||||
<button key={item.label} onClick={() => navigate(item.path)} className="glass-card rounded-2xl p-5 text-left hover:border-primary/30 transition-all group">
|
||||
<div className={`w-8 h-8 rounded-lg bg-card border border-border flex items-center justify-center mb-3 ${item.color}`}>
|
||||
<item.icon className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="font-display font-semibold text-sm mb-1">{item.label}</div>
|
||||
<div className="text-muted-foreground text-xs">{item.desc}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
249
src/pages/Help.tsx
Normal file
249
src/pages/Help.tsx
Normal file
|
|
@ -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<string, React.ElementType> = {
|
||||
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<number | null>(null);
|
||||
|
||||
const filtered = activeCategory === "Tous"
|
||||
? FAQ
|
||||
: FAQ.filter(f => f.category === activeCategory);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{/* Background */}
|
||||
<div className="fixed inset-0 pointer-events-none overflow-hidden">
|
||||
<div className="absolute top-1/4 left-1/4 w-96 h-96 rounded-full bg-primary/8 blur-3xl" />
|
||||
<div className="absolute bottom-1/4 right-1/4 w-80 h-80 rounded-full bg-secondary/8 blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 max-w-3xl mx-auto px-4 py-12">
|
||||
{/* Back */}
|
||||
<button
|
||||
onClick={() => navigate(-1 as any)}
|
||||
className="flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors mb-8 text-sm"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Retour
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="text-center mb-12">
|
||||
<div className="w-16 h-16 rounded-2xl bg-primary/20 border border-primary/40 flex items-center justify-center mx-auto mb-4 glow-teal">
|
||||
<HelpCircle className="w-8 h-8 text-primary" />
|
||||
</div>
|
||||
<h1 className="font-display text-4xl font-bold mb-3">Centre d'aide</h1>
|
||||
<p className="text-muted-foreground text-lg">
|
||||
Trouvez rapidement les réponses à vos questions sur QueueMed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick links */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-10">
|
||||
{[
|
||||
{ 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 (
|
||||
<button
|
||||
key={item.cat}
|
||||
onClick={() => setActiveCategory(item.cat)}
|
||||
className={`glass-card rounded-2xl p-4 flex flex-col items-center gap-2 transition-all hover:border-primary/50 ${
|
||||
activeCategory === item.cat ? "border-primary/60 glow-teal" : ""
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-6 h-6 text-primary" />
|
||||
<span className="text-sm font-medium">{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Category filter */}
|
||||
<div className="flex gap-2 flex-wrap mb-6">
|
||||
{CATEGORIES.map(cat => (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setActiveCategory(cat)}
|
||||
className={`px-4 py-1.5 rounded-full text-sm font-medium border transition-all ${
|
||||
activeCategory === cat
|
||||
? "bg-primary text-primary-foreground border-primary"
|
||||
: "bg-muted/50 border-border/60 text-muted-foreground hover:border-primary/40"
|
||||
}`}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* FAQ */}
|
||||
<div className="space-y-3">
|
||||
{filtered.map((item, i) => {
|
||||
const CatIcon = CATEGORY_ICONS[item.category] || BookOpen;
|
||||
const isOpen = openIndex === i;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`glass-card rounded-2xl overflow-hidden transition-all duration-200 ${isOpen ? "border-primary/40" : ""}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => setOpenIndex(isOpen ? null : i)}
|
||||
className="w-full flex items-center gap-4 p-5 text-left hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg bg-primary/15 border border-primary/30 flex items-center justify-center flex-shrink-0">
|
||||
<CatIcon className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<span className="flex-1 font-medium text-sm leading-snug">{item.q}</span>
|
||||
{isOpen
|
||||
? <ChevronUp className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
: <ChevronDown className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
}
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="px-5 pb-5 pt-0">
|
||||
<div className="ml-12 text-sm text-muted-foreground leading-relaxed border-t border-border/40 pt-4">
|
||||
{item.a}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Contact CTA */}
|
||||
<div className="mt-12 glass-card rounded-3xl p-8 text-center border-primary/20">
|
||||
<AlertCircle className="w-10 h-10 text-secondary mx-auto mb-4" />
|
||||
<h3 className="font-display text-xl font-bold mb-2">Vous ne trouvez pas votre réponse ?</h3>
|
||||
<p className="text-muted-foreground text-sm mb-6">
|
||||
Notre équipe est disponible pour vous aider à configurer et utiliser QueueMed dans votre cabinet.
|
||||
</p>
|
||||
<div className="flex gap-3 justify-center flex-wrap">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate("/dashboard")}
|
||||
className="border-border/60"
|
||||
>
|
||||
<Stethoscope className="w-4 h-4 mr-2" />
|
||||
Tableau de bord
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => window.open("mailto:support@queuemed.fr", "_blank")}
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90 glow-teal"
|
||||
>
|
||||
Contacter le support
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
324
src/pages/Onboarding.tsx
Normal file
324
src/pages/Onboarding.tsx
Normal file
|
|
@ -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<number | null>(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 (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
{/* Background blobs */}
|
||||
<div className="fixed inset-0 pointer-events-none overflow-hidden">
|
||||
<div className="absolute top-1/4 left-1/4 w-96 h-96 rounded-full bg-primary/10 blur-3xl animate-pulse-glow" />
|
||||
<div className="absolute bottom-1/4 right-1/4 w-80 h-80 rounded-full bg-secondary/10 blur-3xl animate-pulse-glow" style={{ animationDelay: "1s" }} />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 w-full max-w-lg">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="flex items-center justify-center gap-2 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary/20 border border-primary/40 flex items-center justify-center glow-teal">
|
||||
<Stethoscope className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<span className="font-display text-xl font-bold gradient-text">QueueMed</span>
|
||||
</div>
|
||||
<h1 className="font-display text-2xl font-bold mb-2">Configuration initiale</h1>
|
||||
<p className="text-muted-foreground text-sm">Configurez votre premier cabinet en 2 minutes</p>
|
||||
</div>
|
||||
|
||||
{/* Step indicators */}
|
||||
<div className="flex items-center justify-center gap-2 mb-8">
|
||||
{STEPS.map((s, i) => (
|
||||
<div key={s.id} className="flex items-center gap-2">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold transition-all duration-300 ${
|
||||
s.id < step ? "bg-primary text-primary-foreground" :
|
||||
s.id === step ? "bg-primary/20 border-2 border-primary text-primary" :
|
||||
"bg-muted text-muted-foreground"
|
||||
}`}>
|
||||
{s.id < step ? <CheckCircle2 className="w-4 h-4" /> : s.id}
|
||||
</div>
|
||||
{i < STEPS.length - 1 && (
|
||||
<div className={`w-12 h-0.5 transition-all duration-300 ${s.id < step ? "bg-primary" : "bg-border"}`} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<div className="glass-card rounded-3xl p-8">
|
||||
{/* Step header */}
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="w-12 h-12 rounded-2xl bg-primary/20 border border-primary/40 flex items-center justify-center glow-teal flex-shrink-0">
|
||||
<StepIcon className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-display text-xl font-bold">{currentStep.title}</h2>
|
||||
<p className="text-muted-foreground text-sm">{currentStep.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 1 — Cabinet info */}
|
||||
{step === 1 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name" className="text-sm font-medium mb-1.5 block">
|
||||
Nom du cabinet <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="Ex: Cabinet Dr. Martin"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
className="bg-muted/50 border-border/60 focus:border-primary"
|
||||
onKeyDown={e => e.key === "Enter" && handleNext()}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="address" className="text-sm font-medium mb-1.5 block">
|
||||
Adresse <span className="text-muted-foreground text-xs">(optionnel)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="address"
|
||||
placeholder="Ex: 12 rue de la Paix, Paris"
|
||||
value={address}
|
||||
onChange={e => setAddress(e.target.value)}
|
||||
className="bg-muted/50 border-border/60 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="phone" className="text-sm font-medium mb-1.5 block">
|
||||
Téléphone <span className="text-muted-foreground text-xs">(optionnel)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
placeholder="Ex: 01 23 45 67 89"
|
||||
value={phone}
|
||||
onChange={e => setPhone(e.target.value)}
|
||||
className="bg-muted/50 border-border/60 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2 — Queue settings */}
|
||||
{step === 2 && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Label className="text-sm font-medium mb-1.5 block">
|
||||
Durée moyenne de consultation
|
||||
</Label>
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
type="range"
|
||||
min={5} max={60} step={5}
|
||||
value={avgConsultation}
|
||||
onChange={e => setAvgConsultation(Number(e.target.value))}
|
||||
className="flex-1 accent-primary"
|
||||
/>
|
||||
<span className="text-primary font-bold w-16 text-right">{avgConsultation} min</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs mt-1">Utilisé pour estimer le temps d'attente des patients.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-sm font-medium mb-1.5 block">
|
||||
Taille maximale de la file
|
||||
</Label>
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
type="range"
|
||||
min={5} max={100} step={5}
|
||||
value={maxQueue}
|
||||
onChange={e => setMaxQueue(Number(e.target.value))}
|
||||
className="flex-1 accent-primary"
|
||||
/>
|
||||
<span className="text-primary font-bold w-16 text-right">{maxQueue} patients</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs mt-1">Au-delà, les nouveaux patients ne peuvent plus rejoindre.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-sm font-medium mb-1.5 block">
|
||||
Rotation du QR code (anti-triche)
|
||||
</Label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{[0, 30, 60, 120, 240].map(v => (
|
||||
<button
|
||||
key={v}
|
||||
onClick={() => setQrRotation(v)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-all ${
|
||||
qrRotation === v
|
||||
? "bg-primary text-primary-foreground border-primary glow-teal"
|
||||
: "bg-muted/50 border-border/60 text-muted-foreground hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
{v === 0 ? "Désactivé" : v < 60 ? `${v} min` : `${v / 60}h`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs mt-2">
|
||||
Le QR code change de token automatiquement pour éviter les partages frauduleux.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3 — Success */}
|
||||
{step === 3 && (
|
||||
<div className="text-center space-y-6">
|
||||
<div className="w-20 h-20 rounded-full bg-green-500/20 border-2 border-green-500/40 flex items-center justify-center mx-auto">
|
||||
<CheckCircle2 className="w-10 h-10 text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-display text-xl font-bold text-green-400 mb-2">Cabinet créé !</h3>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
Votre cabinet <strong className="text-foreground">"{name}"</strong> est configuré.
|
||||
Voici les prochaines étapes pour démarrer.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3 text-left">
|
||||
{[
|
||||
{ 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 => (
|
||||
<div key={item.num} className="flex items-start gap-3 p-3 rounded-xl bg-muted/30 border border-border/40">
|
||||
<span className={`w-6 h-6 rounded-full bg-primary/20 border border-primary/40 flex items-center justify-center text-xs font-bold ${item.color} flex-shrink-0 mt-0.5`}>
|
||||
{item.num}
|
||||
</span>
|
||||
<span className="text-sm text-foreground/80">{item.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 mt-8">
|
||||
{step > 1 && step < 3 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setStep(step - 1)}
|
||||
className="flex-1"
|
||||
disabled={createClinic.isPending}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 mr-1" />
|
||||
Retour
|
||||
</Button>
|
||||
)}
|
||||
{step < 3 ? (
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
disabled={createClinic.isPending}
|
||||
className="flex-1 bg-primary text-primary-foreground hover:bg-primary/90 glow-teal font-semibold h-12"
|
||||
>
|
||||
{createClinic.isPending ? (
|
||||
<><Loader2 className="w-4 h-4 mr-2 animate-spin" />Création...</>
|
||||
) : (
|
||||
<>{step === 2 ? "Créer le cabinet" : "Continuer"}<ChevronRight className="w-4 h-4 ml-1" /></>
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex gap-3 w-full">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate(`/dashboard/queue/${clinicId}`)}
|
||||
className="flex-1"
|
||||
>
|
||||
<QrCode className="w-4 h-4 mr-2" />
|
||||
Voir le QR code
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => navigate("/dashboard")}
|
||||
className="flex-1 bg-primary text-primary-foreground hover:bg-primary/90 glow-teal font-semibold"
|
||||
>
|
||||
Tableau de bord
|
||||
<ChevronRight className="w-4 h-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Skip link */}
|
||||
{step < 3 && (
|
||||
<p className="text-center mt-4 text-sm text-muted-foreground">
|
||||
<button
|
||||
onClick={() => navigate("/dashboard")}
|
||||
className="underline underline-offset-2 hover:text-foreground transition-colors"
|
||||
>
|
||||
Passer pour l'instant
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
227
src/pages/QrPoster.tsx
Normal file
227
src/pages/QrPoster.tsx
Normal file
|
|
@ -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<HTMLDivElement>(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 (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 text-primary animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{/* Controls — hidden on print */}
|
||||
<div className="print:hidden relative z-10 max-w-2xl mx-auto px-4 py-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<button
|
||||
onClick={() => navigate(`/dashboard/queue/${clinicId}`)}
|
||||
className="flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors text-sm"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Retour à la gestion
|
||||
</button>
|
||||
<Button
|
||||
onClick={handlePrint}
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90 glow-teal"
|
||||
>
|
||||
<Printer className="w-4 h-4 mr-2" />
|
||||
Imprimer l'affiche
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="glass-card rounded-2xl p-4 mb-6 flex items-start gap-3">
|
||||
<QrCode className="w-5 h-5 text-primary flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<strong className="text-foreground">Conseils d'impression :</strong> 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.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Printable poster */}
|
||||
<div
|
||||
ref={printRef}
|
||||
className="print:m-0 max-w-2xl mx-auto px-4 pb-12 print:p-0 print:max-w-none"
|
||||
>
|
||||
<div
|
||||
className="bg-white text-gray-900 rounded-3xl print:rounded-none overflow-hidden shadow-2xl print:shadow-none"
|
||||
style={{ fontFamily: "'Inter', 'Segoe UI', sans-serif" }}
|
||||
>
|
||||
{/* Header band */}
|
||||
<div style={{ background: "linear-gradient(135deg, #0d9488, #0f766e)", padding: "32px 40px" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "12px", marginBottom: "8px" }}>
|
||||
<div style={{
|
||||
width: "40px", height: "40px", borderRadius: "10px",
|
||||
background: "rgba(255,255,255,0.2)", display: "flex",
|
||||
alignItems: "center", justifyContent: "center"
|
||||
}}>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2.5">
|
||||
<path d="M3 9h6V3H3v6zm2-4h2v2H5V5zm8-2v6h6V3h-6zm4 4h-2V5h2v2zM3 21h6v-6H3v6zm2-4h2v2H5v-2zm13-2h-3v2h2v2h-2v2h3v-3h2v-3h-2v2zm-5 6h2v-2h-2v2zm-3-6h2v2h-2v-2zm-2-2h2v2h-2v-2zm2-2h2v2h-2v-2zm2 2h2v2h-2v-2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span style={{ color: "white", fontSize: "22px", fontWeight: "800", letterSpacing: "-0.02em" }}>
|
||||
QueueMed
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ color: "rgba(255,255,255,0.85)", fontSize: "14px", margin: 0 }}>
|
||||
Salle d'attente virtuelle
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div style={{ padding: "40px", textAlign: "center" }}>
|
||||
<h1 style={{
|
||||
fontSize: "28px", fontWeight: "800", color: "#0f172a",
|
||||
marginBottom: "8px", lineHeight: "1.2"
|
||||
}}>
|
||||
{clinic?.name ?? "Cabinet médical"}
|
||||
</h1>
|
||||
{clinic?.address && (
|
||||
<p style={{ color: "#64748b", fontSize: "14px", marginBottom: "32px" }}>
|
||||
📍 {clinic.address}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p style={{
|
||||
fontSize: "18px", fontWeight: "600", color: "#1e293b",
|
||||
marginBottom: "8px"
|
||||
}}>
|
||||
Rejoignez la file d'attente sans attendre ici
|
||||
</p>
|
||||
<p style={{ color: "#64748b", fontSize: "14px", marginBottom: "32px" }}>
|
||||
Scannez le QR code avec votre téléphone et suivez votre position en temps réel
|
||||
</p>
|
||||
|
||||
{/* QR Code */}
|
||||
<div style={{
|
||||
display: "inline-block",
|
||||
padding: "20px",
|
||||
borderRadius: "20px",
|
||||
border: "3px solid #e2e8f0",
|
||||
background: "white",
|
||||
marginBottom: "32px",
|
||||
boxShadow: "0 8px 32px rgba(0,0,0,0.08)"
|
||||
}}>
|
||||
{qrDataUrl ? (
|
||||
<img
|
||||
src={qrDataUrl}
|
||||
alt="QR Code file d'attente"
|
||||
style={{ width: "220px", height: "220px", display: "block" }}
|
||||
/>
|
||||
) : (
|
||||
<div style={{
|
||||
width: "220px", height: "220px",
|
||||
background: "#f1f5f9", borderRadius: "12px",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
color: "#94a3b8", fontSize: "14px"
|
||||
}}>
|
||||
QR Code non disponible
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Steps */}
|
||||
<div style={{
|
||||
display: "grid", gridTemplateColumns: "1fr 1fr 1fr",
|
||||
gap: "16px", marginBottom: "32px"
|
||||
}}>
|
||||
{[
|
||||
{ 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 => (
|
||||
<div key={step.num} style={{
|
||||
padding: "16px 12px",
|
||||
borderRadius: "16px",
|
||||
background: "#f8fafc",
|
||||
border: "1px solid #e2e8f0"
|
||||
}}>
|
||||
<div style={{ fontSize: "28px", marginBottom: "8px" }}>{step.icon}</div>
|
||||
<div style={{ fontSize: "13px", fontWeight: "700", color: "#0f172a", marginBottom: "4px" }}>
|
||||
{step.title}
|
||||
</div>
|
||||
<div style={{ fontSize: "11px", color: "#64748b", lineHeight: "1.4" }}>
|
||||
{step.desc}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Info box */}
|
||||
<div style={{
|
||||
padding: "14px 20px",
|
||||
borderRadius: "12px",
|
||||
background: "#f0fdf4",
|
||||
border: "1px solid #bbf7d0",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "10px",
|
||||
textAlign: "left"
|
||||
}}>
|
||||
<span style={{ fontSize: "20px" }}>✅</span>
|
||||
<div>
|
||||
<strong style={{ fontSize: "13px", color: "#166534" }}>
|
||||
Aucune application à installer
|
||||
</strong>
|
||||
<p style={{ fontSize: "12px", color: "#15803d", margin: "2px 0 0 0" }}>
|
||||
Fonctionne directement dans votre navigateur. Gratuit pour les patients.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* No smartphone note */}
|
||||
<p style={{
|
||||
marginTop: "20px", fontSize: "12px", color: "#94a3b8",
|
||||
borderTop: "1px solid #f1f5f9", paddingTop: "16px"
|
||||
}}>
|
||||
Pas de smartphone ? Demandez un ticket imprimé à l'accueil.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{
|
||||
background: "#f8fafc", padding: "16px 40px",
|
||||
display: "flex", justifyContent: "space-between",
|
||||
alignItems: "center", borderTop: "1px solid #e2e8f0"
|
||||
}}>
|
||||
<span style={{ fontSize: "12px", color: "#94a3b8" }}>
|
||||
Propulsé par QueueMed
|
||||
</span>
|
||||
<span style={{ fontSize: "12px", color: "#94a3b8" }}>
|
||||
queuemed.fr
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Print styles */}
|
||||
<style>{`
|
||||
@media print {
|
||||
body { background: white !important; }
|
||||
.print\\:hidden { display: none !important; }
|
||||
@page { margin: 0; size: A4; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
299
src/pages/QueueManagement.tsx
Normal file
299
src/pages/QueueManagement.tsx
Normal file
|
|
@ -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<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>
|
||||
);
|
||||
}
|
||||
113
src/server/onboarding.test.ts
Normal file
113
src/server/onboarding.test.ts
Normal file
|
|
@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
35
todo.md
35
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue