commit d24d0c3e70b61bf654abd8a8d9afcf105a2422f1 Author: Hermes Date: Sat Apr 25 03:34:16 2026 +0000 init: project skeleton with reference files and CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..927a398 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,106 @@ +# QueueMed — Salle d'attente virtuelle pour cabinets médicaux + +## Architecture +- **Frontend**: React 19, Vite 6, Tailwind CSS 4, shadcn/ui, wouter, Framer Motion, Recharts, Socket.io-client +- **Backend**: Express 4, tRPC 11, Socket.io 4, Drizzle ORM +- **Database**: MySQL 8 +- **Auth**: JWT + session cookie (simple email/password login, no OAuth) +- **QR Code**: qrcode npm package with rotating anti-cheat tokens +- **Deploy**: Docker + docker-compose, Nginx Proxy Manager for HTTPS + +## Theme — MEDICAL LIGHT +- **Primary**: #10b981 (emerald-500) and #06b6d4 (cyan-500) +- **Background**: white / #f0fdf4 (green-50) / #ecfeff (cyan-50) +- **Cards**: white with subtle shadow, glass-morphism (backdrop-blur, bg-white/70) +- **Accents**: #0d9488 (teal-600) for CTAs, #f97316 (orange-500) for alerts +- **Feel**: clean, hygienic, medical — light green/cyan, translucent panels, rounded corners +- **Font**: Inter (Google Fonts) + +## Database Schema (see docs_ref/schema.ts) +5 tables: users, subscriptions, clinics, queueEntries, analyticsEvents + +## Key Routes +| Route | Page | Access | +|---|---|---| +| / | Landing page (hero, features, pricing, testimonials) | Public | +| /login | Login page | Public | +| /dashboard | Doctor dashboard (KPIs, clinic list, quick actions) | Auth | +| /dashboard/clinics | Manage clinics (CRUD, QR code, settings) | Auth | +| /dashboard/queue/:clinicId | Real-time queue management | Auth | +| /dashboard/analytics | Charts, CSV export, AI recommendations | Auth | +| /dashboard/subscription | Subscription plans, trial, blocking | Auth | +| /display/:clinicId | Display screen for tablet/monitor | Public | +| /queue/:token | Patient interface (live position, alerts) | Public | +| /ticket/:entryId | Printable ticket | Public | + +## Project Structure +``` +/home/ubuntu/queue-med-deploy/ +├── client/ +│ ├── src/ +│ │ ├── main.tsx # React entry + wouter router +│ │ ├── App.tsx # Layout shell +│ │ ├── lib/ +│ │ │ └── trpc.ts # tRPC client setup +│ │ ├── components/ +│ │ │ └── ui/ # shadcn/ui components +│ │ ├── _core/ +│ │ │ └── hooks/ +│ │ │ └── useAuth.ts +│ │ └── pages/ +│ │ ├── Home.tsx # Landing +│ │ ├── Login.tsx +│ │ ├── Dashboard.tsx +│ │ ├── DoctorClinics.tsx +│ │ ├── QueueManagement.tsx +│ │ ├── Analytics.tsx +│ │ ├── PatientQueue.tsx +│ │ ├── DisplayScreen.tsx +│ │ ├── SubscriptionPage.tsx +│ │ ├── PrintTicket.tsx +│ │ ├── Onboarding.tsx +│ │ ├── Help.tsx +│ │ └── QrPoster.tsx +│ └── index.html +├── server/ +│ ├── _core/ +│ │ ├── index.ts # Express + Socket.io server +│ │ ├── trpc.ts # tRPC setup +│ │ └── context.ts # Auth context +│ ├── routers.ts # All tRPC procedures +│ ├── db.ts # Drizzle helpers +│ ├── schema.ts # Drizzle schema +│ └── auth.ts # JWT auth logic +├── shared/ +│ └── types.ts # Shared types +├── drizzle.config.ts +├── vite.config.ts +├── tsconfig.json +├── package.json +├── Dockerfile +├── docker-compose.yml +└── .dockerignore +``` + +## Environment Variables +- DATABASE_URL — MySQL connection string +- JWT_SECRET — Secret for JWT signing +- PORT — Server port (default 5000) +- NODE_ENV — production/development + +## Socket.io Rooms +- clinic:{clinicId} — Doctor + display screen +- patient:{patientToken} — Individual patient +- display:{clinicId} — Display screen only + +## Commands +- pnpm dev — Start dev server +- pnpm build — Production build +- pnpm db:push — Push Drizzle migrations +- pnpm start — Start production server + +## CRITICAL NOTES +- Use existing pages in src_ref/ as reference for UI patterns and tRPC calls +- The QR token rotation system is anti-cheat: tokens expire on schedule +- Subscription middleware blocks sensitive procedures when expired +- Socket.io is initialized in server/_core/index.ts and exposed globally diff --git a/MANUS_HANDOFF.md b/MANUS_HANDOFF.md new file mode 100644 index 0000000..b482bee --- /dev/null +++ b/MANUS_HANDOFF.md @@ -0,0 +1,106 @@ +# MANUS_HANDOFF — QueueMed v1.0.0 + +> Fichier de passation pour la continuité du développement. +> Dernière mise à jour : 2026-02-27 — William MERI + +--- + +## État actuel du projet + +**Version** : 1.0.0 +**Statut** : Production-ready (fonctionnalités core complètes, Stripe à activer) +**Tests** : 8/8 passent, 0 erreurs TypeScript +**Checkpoint Manus** : `a63c623c` + +--- + +## Ce qui est implémenté + +### Backend (server/) +- `routers.ts` — 20+ procédures tRPC : auth, clinics, queue, analytics +- `db.ts` — Helpers Drizzle pour toutes les tables +- `_core/index.ts` — Serveur Express + Socket.io intégré +- Middleware `subscriptionProcedure` — Bloque les procédures si abonnement expiré +- Rotation automatique du QR code (configurable par cabinet) +- Procédure `analytics.exportCsv` — Export CSV des événements + +### Frontend (client/src/) +- `pages/Home.tsx` — Landing page cinématique (hero, features, how-it-works, témoignages, pricing, CTA) +- `pages/Dashboard.tsx` — Tableau de bord médecin (KPIs, liste cabinets, actions rapides) +- `pages/DoctorClinics.tsx` — Gestion des cabinets (CRUD, QR code, paramètres) +- `pages/QueueManagement.tsx` — Gestion file temps réel (appel suivant, absent, retirer, imprimer ticket) +- `pages/Analytics.tsx` — Graphiques affluence, export CSV, recommandations IA +- `pages/PatientQueue.tsx` — Interface patient (position live, temps estimé, alertes) +- `pages/DisplayScreen.tsx` — Écran d'affichage tablette (numéro animé, ticker, connexion status) +- `pages/SubscriptionPage.tsx` — Page abonnement (plans, essai gratuit, blocage) +- `pages/SubscriptionBlocked.tsx` — Page de blocage après expiration +- `pages/PrintTicket.tsx` — Ticket imprimable pour patients sans smartphone + +### Base de données (drizzle/schema.ts) +``` +users, subscriptions, clinics, queueEntries, analyticsEvents +``` + +--- + +## Ce qui reste à faire + +### Priorité haute +- [ ] **Intégration Stripe** — L'utilisateur doit fournir ses clés Stripe (STRIPE_SECRET_KEY, VITE_STRIPE_PUBLISHABLE_KEY, STRIPE_WEBHOOK_SECRET). Utiliser `webdev_add_feature stripe` une fois les clés configurées dans Settings → Payment. +- [ ] **Notifications SMS** — Intégrer Twilio pour alerter les patients par SMS quand leur tour approche (alternative aux notifications push navigateur). + +### Priorité moyenne +- [ ] **Page `/subscription/plans`** — Checkout Stripe réel avec redirection vers portail client. +- [ ] **Webhook Stripe** — Route `/api/stripe/webhook` pour gérer `checkout.session.completed`, `invoice.paid`, `customer.subscription.deleted`. +- [ ] **Tests supplémentaires** — Couvrir les procédures queue.callNext, queue.markAbsent, analytics.get. + +### Priorité basse +- [ ] **Mode multi-praticiens** — Plusieurs médecins par cabinet (table `clinicMembers`). +- [ ] **Rapports PDF** — Export PDF hebdomadaire automatique. +- [ ] **Application mobile** — React Native avec les mêmes APIs tRPC. + +--- + +## Informations techniques importantes + +### Socket.io +Le serveur Socket.io est initialisé dans `server/_core/index.ts` et exposé globalement via `(global as any).__socketIo`. Les procédures tRPC l'utilisent via `getIo()` dans `routers.ts`. + +Rooms Socket.io : +- `clinic:{clinicId}` — Médecin + écran d'affichage +- `patient:{patientToken}` — Patient individuel +- `display:{clinicId}` — Écran d'affichage uniquement + +### QR Code anti-triche +Le token QR est stocké dans `clinics.qrToken`. La rotation est déclenchée par `rotateQrToken()` dans `db.ts`. La fréquence est configurable via `clinics.qrRotationMinutes` (0 = pas de rotation). + +### Abonnement +- Essai gratuit : 30 jours à partir de la première connexion +- Statuts : `trial` → `active` → `expired` / `canceled` +- Le middleware `subscriptionProcedure` vérifie `isSubscriptionActive()` avant chaque opération sensible + +### Design system +- Thème : dark, palette teal (#0d9488) + orange (#f97316) +- Classes utilitaires custom : `.gradient-text`, `.glass-card`, `.glow-teal`, `.text-glow-teal`, `.queue-number`, `.animate-ticker` +- Police display : Inter (Google Fonts) + +--- + +## Commandes utiles + +```bash +pnpm dev # Démarrer le serveur de développement +pnpm test # Exécuter les tests Vitest +pnpm db:push # Pousser les migrations Drizzle +pnpm build # Build de production +npx tsc --noEmit # Vérifier les types TypeScript +``` + +--- + +## Contacts et ressources + +- **Auteur** : William MERI +- **Dépôt GitHub** : https://github.com/Tarzzan/queue-med +- **Documentation Stripe** : https://stripe.com/docs/billing/subscriptions +- **Documentation Socket.io** : https://socket.io/docs/v4/ diff --git a/MODE_OPERATOIRE.md b/MODE_OPERATOIRE.md new file mode 100644 index 0000000..df97137 --- /dev/null +++ b/MODE_OPERATOIRE.md @@ -0,0 +1,252 @@ +# QueueMed — Mode Opératoire Complet + +**Version** : 1.2 — Février 2026 +**Auteur** : William MERI +**Application** : [QueueMed](https://queuemed.manus.space) — Salle d'attente virtuelle pour cabinets médicaux + +--- + +## Présentation générale + +QueueMed est une plateforme SaaS de gestion de file d'attente numérique destinée aux médecins qui reçoivent sans rendez-vous. Elle repose sur trois acteurs : le **médecin** (administrateur de la file), le **patient** (utilisateur du service), et l'**écran d'affichage** (tablette ou moniteur en salle d'attente). Chacun interagit avec une interface dédiée, sans que les patients n'aient besoin de créer un compte. + +--- + +## Partie 1 — Guide du médecin + +### 1.1 Première connexion et création de compte + +Pour accéder à QueueMed, le médecin se rend sur la page d'accueil de l'application et clique sur **"Démarrer l'essai gratuit"** ou **"Connexion"**. L'authentification s'effectue via Manus OAuth, qui ne nécessite aucune saisie de mot de passe : un lien de connexion est envoyé par email ou via le portail Manus. + +À la première connexion, un **essai gratuit de 30 jours** est automatiquement activé, donnant accès à l'intégralité des fonctionnalités sans restriction. Un compte à rebours visible dans le tableau de bord indique les jours restants. + +### 1.2 Création d'un cabinet + +Depuis le tableau de bord, le médecin accède à **"Mes cabinets"** via le menu latéral, puis clique sur **"Nouveau cabinet"**. Il renseigne les informations suivantes : + +| Champ | Description | Exemple | +|---|---|---| +| Nom du cabinet | Nom affiché sur l'écran et les tickets | Cabinet Dr. Martin | +| Adresse | Optionnel, pour référence | 12 rue de la Paix, Paris | +| Durée de consultation | Durée estimée par patient (en minutes) | 15 min | +| Taille max de la file | Nombre maximum de patients simultanés | 20 | +| Rotation du QR code | Fréquence de renouvellement du token anti-triche | 60 min | + +Une fois le cabinet créé, un **QR code unique** est automatiquement généré. Ce QR code est l'élément central du système : il doit être affiché à l'accueil du cabinet (imprimé ou sur écran). + +### 1.3 Affichage et impression du QR code + +Dans la fiche du cabinet, cliquer sur **"Voir le QR code"** ouvre une fenêtre avec le QR code en haute résolution. Deux options sont disponibles : + +- **Impression directe** : cliquer sur "Imprimer" pour obtenir une feuille A4 avec le QR code, le nom du cabinet et les instructions pour les patients. +- **Affichage numérique** : placer le QR code sur un écran d'accueil ou une tablette à l'entrée du cabinet. + +> **Note anti-triche** : Le QR code contient un token unique qui se renouvelle automatiquement selon la fréquence configurée (par défaut toutes les 60 minutes). Un patient qui tenterait de partager son lien à un tiers verrait ce dernier arriver en fin de file, puisque le token expiré ne serait plus valide pour rejoindre la file active. + +### 1.4 Ouverture et gestion de la file d'attente + +Le médecin ouvre la file depuis **"Gestion de la file"** dans le menu, puis sélectionne le cabinet concerné. L'interface présente : + +- Un bouton **"Ouvrir la file"** pour commencer à accepter des patients. +- La liste en temps réel de tous les patients en attente, avec leur numéro, prénom (si renseigné), position et temps d'attente estimé. +- Un bouton **"Appeler le suivant"** pour appeler le prochain patient dans la file. + +Lorsque le médecin appelle un patient, trois événements se produisent simultanément : le numéro s'affiche en grand sur l'écran d'affichage en salle, le patient reçoit une notification push sur son téléphone, et le patient suivant reçoit une alerte "votre tour approche". + +### 1.5 Actions disponibles sur chaque patient + +Pour chaque entrée dans la file, le médecin dispose des actions suivantes : + +| Action | Description | Quand l'utiliser | +|---|---|---| +| **Appeler** | Appelle ce patient spécifiquement | Patient déjà présent, priorité médicale | +| **Absent** | Marque le patient comme absent | Patient ne se présente pas après appel | +| **Retirer** | Retire le patient de la file | Patient parti, annulation | +| **Imprimer ticket** | Génère un ticket imprimable | Patient sans smartphone | + +### 1.6 Impression de tickets pour patients sans smartphone + +Pour les patients qui ne disposent pas de smartphone, le médecin ou le personnel d'accueil peut générer un ticket physique depuis l'interface de gestion de file. Cliquer sur **"Imprimer un ticket"**, saisir optionnellement le prénom du patient, puis imprimer le ticket généré. Ce ticket comporte le numéro de file, la position actuelle et le temps d'attente estimé. + +### 1.7 Fermeture et réinitialisation de la file + +En fin de journée, cliquer sur **"Fermer la file"** empêche de nouveaux patients de rejoindre la file. Le bouton **"Réinitialiser"** remet tous les compteurs à zéro et prépare la file pour le lendemain. Cette action est irréversible et doit être effectuée en fin de journée uniquement. + +### 1.8 Écran d'affichage (tablette ou moniteur) + +L'écran d'affichage est une interface dédiée accessible à l'URL `/display/{identifiant-cabinet}`. Elle est conçue pour être affichée en permanence sur une tablette ou un moniteur en salle d'attente, sans interaction utilisateur. + +Pour configurer l'écran d'affichage : + +1. Dans la fiche du cabinet, copier le **lien de l'écran d'affichage**. +2. Ouvrir ce lien sur la tablette ou le moniteur dédié. +3. Activer le mode plein écran du navigateur (touche F11 sur PC, ou option "Ajouter à l'écran d'accueil" sur tablette). +4. L'écran se met à jour automatiquement en temps réel via WebSocket. + +L'écran affiche en permanence : le numéro en cours d'appel (en très grand, avec animation), le nombre de patients en attente, le temps d'attente estimé, les prochains numéros, et un ticker défilant avec les instructions pour les patients. + +### 1.9 Consultation des analytics + +La section **"Analytics"** du tableau de bord présente des graphiques d'affluence par heure et par jour de la semaine, le temps d'attente moyen, les recommandations d'optimisation générées automatiquement, et la possibilité d'exporter toutes les données au format CSV. + +Pour exporter les données, cliquer sur le bouton portant le nom du cabinet dans la section "Export des données". Un fichier CSV est téléchargé avec l'historique complet des événements (arrivées, appels, absences) sur la période sélectionnée. + +### 1.10 Gestion de l'abonnement + +L'essai gratuit dure 30 jours. À son expiration, l'accès aux fonctionnalités de gestion est bloqué jusqu'à souscription d'un plan payant. La page **"Abonnement"** dans le menu propose deux plans : + +| Plan | Prix | Cabinets | Fonctionnalités clés | +|---|---|---|---| +| **Basic** | 29€/mois | 2 cabinets | File illimitée, QR code rotatif, analytics de base | +| **Pro** | 59€/mois | Illimités | Analytics IA, prédictions, export CSV, API | + +Le paiement s'effectue via Stripe (carte bancaire). L'abonnement est renouvelé automatiquement chaque mois et peut être annulé à tout moment depuis cette même page. + +--- + +## Partie 2 — Guide du patient + +### 2.1 Rejoindre la file d'attente + +Le patient arrive au cabinet et aperçoit le QR code affiché à l'accueil (sur une feuille imprimée, une tablette, ou un écran). Il suit ces étapes : + +1. **Ouvrir l'appareil photo** de son smartphone (ou une application de scan QR code). +2. **Pointer l'appareil photo** vers le QR code affiché. +3. **Appuyer sur le lien** qui apparaît automatiquement à l'écran. +4. Une page web s'ouvre dans le navigateur — **aucune application à installer**. +5. Optionnellement, saisir son prénom pour que le médecin puisse l'identifier. +6. Cliquer sur **"Rejoindre la file"**. + +Le patient reçoit immédiatement son numéro de ticket et sa position dans la file. + +### 2.2 Suivi de sa position en temps réel + +Une fois dans la file, la page affiche en permanence : + +- Son **numéro de ticket** (ex. : 007) +- Sa **position actuelle** dans la file (ex. : 3ème) +- Le **temps d'attente estimé** (ex. : ~45 min) +- Une **barre de progression** visuelle indiquant l'avancement + +La page se met à jour automatiquement sans rechargement. Le patient peut garder cette page ouverte sur son téléphone et vaquer à ses occupations — faire ses courses, attendre dans sa voiture, prendre un café à proximité. + +### 2.3 Recevoir les alertes + +Lorsque le tour du patient approche (généralement quand il est 2ème dans la file), une **notification push** est envoyée sur son téléphone. Pour recevoir ces notifications, le patient doit accepter la permission de notifications lors de sa première visite sur la page. + +Lorsque son numéro est appelé, une alerte plus urgente s'affiche sur la page et une notification push est envoyée. Le patient doit alors se présenter au cabinet dans les minutes suivantes. + +> **Conseil** : Rester à moins de 5 minutes du cabinet pour être sûr d'être présent au bon moment. Si le patient est marqué absent par le médecin, il est retiré de la file et devra rescanner le QR code pour rejoindre à nouveau la file. + +### 2.4 Patients sans smartphone + +Les patients qui ne disposent pas de smartphone peuvent demander un **ticket imprimé** au personnel d'accueil. Ce ticket comporte leur numéro de file et leur position. Ils doivent rester en salle d'attente pour surveiller l'écran d'affichage et se présenter lorsque leur numéro est affiché. + +--- + +## Partie 3 — Guide de déploiement technique + +### 3.1 Prérequis matériels recommandés + +| Équipement | Spécifications minimales | Usage | +|---|---|---| +| Tablette d'affichage | Écran 10" minimum, WiFi, navigateur Chrome/Safari | Écran d'affichage en salle | +| Imprimante | Imprimante standard A4 | Impression QR code et tickets | +| Connexion internet | 4G ou WiFi stable | Mise à jour temps réel | +| Smartphone médecin | iOS 14+ ou Android 10+ | Gestion de la file en mobilité | + +### 3.2 Installation de l'écran d'affichage + +L'écran d'affichage ne nécessite aucune installation logicielle. Il s'agit d'une page web accessible depuis n'importe quel navigateur moderne. + +**Procédure d'installation sur tablette Android :** + +1. Ouvrir Chrome sur la tablette. +2. Naviguer vers l'URL de l'écran d'affichage (disponible dans la fiche cabinet). +3. Appuyer sur les trois points en haut à droite → "Ajouter à l'écran d'accueil". +4. Nommer le raccourci "QueueMed Affichage". +5. Depuis l'écran d'accueil, ouvrir l'application et activer le plein écran. +6. Désactiver la mise en veille de la tablette (Paramètres → Affichage → Mise en veille → Jamais). + +**Procédure d'installation sur tablette iPad :** + +1. Ouvrir Safari sur l'iPad. +2. Naviguer vers l'URL de l'écran d'affichage. +3. Appuyer sur l'icône de partage → "Sur l'écran d'accueil". +4. Depuis l'écran d'accueil, ouvrir l'application en mode plein écran. +5. Activer le mode guidé (Paramètres → Accessibilité → Mode guidé) pour empêcher toute interaction accidentelle. + +### 3.3 Affichage du QR code à l'accueil + +Le QR code doit être visible et accessible dès l'entrée du cabinet. Plusieurs options sont possibles : + +**Option A — Impression papier (recommandée pour démarrer) :** Depuis la fiche cabinet, cliquer sur "Imprimer le QR code". Une page A4 est générée avec le QR code en grand format, le nom du cabinet et les instructions. Plastifier la feuille et la placer dans un porte-document à l'accueil. + +**Option B — Affichage numérique :** Afficher le QR code sur un écran dédié à l'accueil, ou sur la même tablette que l'écran d'affichage en mode split-screen. + +**Option C — Affichage sur la tablette d'affichage :** L'écran d'affichage inclut un message en bas de page invitant les patients à scanner le QR code. Placer la tablette à un endroit visible depuis l'entrée. + +### 3.4 Configuration de la rotation du QR code + +La rotation automatique du QR code est une mesure anti-triche qui invalide les liens partagés. La fréquence recommandée selon le contexte : + +| Contexte | Fréquence recommandée | Justification | +|---|---|---| +| Cabinet à forte affluence | 30 minutes | Renouvellement fréquent, risque de triche élevé | +| Cabinet standard | 60 minutes (défaut) | Équilibre sécurité/praticité | +| Cabinet rural, faible affluence | 4 heures | Moins de risque, simplicité | +| Désactivée (0) | Jamais | Pour tests ou contextes de confiance totale | + +### 3.5 Gestion des pannes et cas particuliers + +**Perte de connexion internet :** 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. + +**Patient qui ne se présente pas :** Le médecin marque le patient comme "Absent" dans l'interface de gestion. Le patient est retiré de la file et les positions des autres patients se mettent à jour automatiquement. + +**File trop longue :** Si la file atteint la taille maximale configurée, les nouveaux patients ne peuvent plus rejoindre. Augmenter la taille maximale dans les paramètres du cabinet, ou fermer temporairement la file. + +**Réinitialisation d'urgence :** En cas de problème grave (doublon de numéros, file corrompue), utiliser le bouton "Réinitialiser la file" dans l'interface de gestion. Cette action remet tous les compteurs à zéro. + +--- + +## Partie 4 — Bonnes pratiques + +### 4.1 Pour le médecin + +Une bonne pratique consiste à ouvrir la file **15 à 20 minutes avant l'heure d'ouverture du cabinet**, afin que les premiers patients puissent s'inscrire à l'avance. Cela évite l'effet de ruée à l'ouverture et permet une meilleure répartition de l'affluence. + +Il est recommandé de **configurer une durée de consultation réaliste** (généralement 10 à 20 minutes selon la spécialité). Cette valeur est utilisée pour calculer les temps d'attente estimés affichés aux patients. Une sous-estimation crée de la frustration ; une surestimation est préférable. + +En fin de journée, **réinitialiser systématiquement la file** pour repartir de zéro le lendemain. Cela évite les confusions avec les numéros de la veille. + +### 4.2 Pour le personnel d'accueil + +Le personnel d'accueil joue un rôle clé dans l'adoption du système. Il est recommandé de **briefer les patients à l'entrée** : expliquer brièvement le fonctionnement (scan QR code → suivre sur téléphone → revenir quand alerté), et proposer un ticket imprimé aux patients qui ne savent pas utiliser un smartphone. + +Afficher une **affiche explicative** à côté du QR code avec les étapes en images facilite l'autonomie des patients et réduit les questions au personnel. + +### 4.3 Communication aux patients + +Pour maximiser l'adoption, il est conseillé d'informer les patients habituels du cabinet en amont. Un message simple sur l'ordonnancier, une affiche en salle d'attente, ou un message sur le répondeur téléphonique suffit : + +> *"Notre cabinet utilise désormais QueueMed, une file d'attente virtuelle. À votre arrivée, scannez le QR code à l'accueil pour rejoindre la file depuis votre téléphone. Vous pouvez attendre où vous le souhaitez et serez alerté quand votre tour approche."* + +--- + +## Résumé des URLs importantes + +| Page | URL | Accès | +|---|---|---| +| Accueil / Landing page | `/` | Public | +| Tableau de bord médecin | `/dashboard` | Médecin connecté | +| Gestion des cabinets | `/dashboard/clinics` | Médecin connecté | +| Gestion de la file | `/dashboard/queue/{id}` | Médecin connecté | +| Analytics | `/dashboard/analytics` | Médecin connecté | +| Abonnement | `/dashboard/subscription` | Médecin connecté | +| **Écran d'affichage** | `/display/{id-cabinet}` | Public (tablette salle) | +| **Interface patient** | `/q/{id-cabinet}/{token}` | Public (via QR code) | +| Ticket imprimable | `/ticket/{id-entrée}` | Public | + +--- + +*Document rédigé par William MERI — QueueMed v1.2 — Février 2026* diff --git a/README.md b/README.md new file mode 100644 index 0000000..d1333f2 --- /dev/null +++ b/README.md @@ -0,0 +1,118 @@ +# QueueMed — Salle d'attente virtuelle pour cabinets médicaux + +> **Plateforme SaaS** de gestion de file d'attente numérique pour les médecins qui reçoivent sans rendez-vous. +> Conçue et développée par **William MERI** — v1.0.0 + +--- + +## Présentation + +QueueMed transforme la salle d'attente physique en une file d'attente virtuelle accessible depuis n'importe quel smartphone. Les patients scannent un QR code anti-triche à l'accueil, suivent leur position en temps réel et reçoivent une alerte quand leur tour approche — leur permettant d'attendre où ils le souhaitent. + +Les médecins bénéficient d'un tableau de bord complet pour gérer la file, appeler les patients, consulter des analytics d'affluence et gérer plusieurs cabinets depuis une interface unifiée. + +--- + +## Fonctionnalités principales + +| Fonctionnalité | Description | +|---|---| +| **QR Code anti-triche** | Token unique rotatif par cabinet (configurable : 5 min à 24h). Impossible de partager ou falsifier sa position. | +| **File d'attente temps réel** | Mise à jour instantanée via WebSocket (Socket.io). Position, temps estimé, numéro de ticket. | +| **Écran d'affichage** | Interface tablette/moniteur avec numéro appelé animé, ticker défilant et indicateur de connexion. | +| **Interface patient** | Aucun compte requis. Scan QR → suivi live → alerte au bon moment. | +| **Tableau de bord médecin** | Gestion de file, appel du suivant, marquage absent, réinitialisation, statistiques du jour. | +| **Multi-cabinets** | Un médecin peut gérer plusieurs salles d'attente depuis un seul compte. | +| **Tickets imprimables** | Numéro unique pour les patients sans smartphone. Inclusion totale garantie. | +| **Analytics avancés** | Affluence par heure, par jour, temps d'attente moyen, recommandations IA, export CSV. | +| **Abonnement mensuel** | Essai gratuit 30 jours, puis blocage si non abonné. Prêt pour intégration Stripe. | +| **Notifications push** | Alertes navigateur quand le tour approche (Web Push API). | + +--- + +## Stack technique + +| Couche | Technologie | +|---|---| +| **Frontend** | React 19, Tailwind CSS 4, shadcn/ui, Recharts, Framer Motion | +| **Backend** | Express 4, tRPC 11, Socket.io 4 | +| **Base de données** | MySQL/TiDB via Drizzle ORM | +| **Auth** | Manus OAuth (JWT, session cookie) | +| **QR Code** | `qrcode` npm package | +| **Tests** | Vitest (8 tests, 0 erreurs TS) | +| **Déploiement** | Manus Hosting (CDN, HTTPS, domaine personnalisé) | + +--- + +## Architecture de la base de données + +``` +users → Comptes médecins (OAuth) +subscriptions → Abonnements (trial/active/expired/canceled) +clinics → Cabinets médicaux (1 médecin → N cabinets) +queueEntries → Entrées dans la file (token anti-triche, position, statut) +analyticsEvents → Événements pour les graphiques d'affluence +``` + +--- + +## Routes de l'application + +| Route | Description | Accès | +|---|---|---| +| `/` | Landing page (hero, features, pricing, témoignages) | Public | +| `/dashboard` | Tableau de bord médecin | Authentifié | +| `/dashboard/clinics` | Gestion des cabinets | Authentifié | +| `/dashboard/queue/:clinicId` | Gestion de la file en temps réel | Authentifié | +| `/dashboard/analytics` | Analytics et export CSV | Authentifié | +| `/display/:clinicId` | Écran d'affichage tablette/moniteur | Public | +| `/queue/:token` | Interface patient (suivi temps réel) | Public | +| `/ticket/:entryId` | Page ticket imprimable | Public | + +--- + +## Variables d'environnement + +Toutes les variables sont injectées automatiquement par la plateforme Manus. Aucune configuration manuelle requise. + +Pour l'intégration Stripe (à activer) : +- `STRIPE_SECRET_KEY` — Clé secrète Stripe +- `VITE_STRIPE_PUBLISHABLE_KEY` — Clé publique Stripe +- `STRIPE_WEBHOOK_SECRET` — Secret webhook Stripe + +--- + +## Lancer le projet en local + +```bash +pnpm install +pnpm db:push +pnpm dev +``` + +--- + +## Tests + +```bash +pnpm test +# 8 tests, 0 erreurs TypeScript +``` + +--- + +## Roadmap (prochaines versions) + +- [ ] Intégration Stripe complète (checkout, webhooks, portail client) +- [ ] Notifications SMS (Twilio) +- [ ] Application mobile React Native +- [ ] Intégration agenda médecin (Doctolib API) +- [ ] Mode multi-praticiens par cabinet +- [ ] Rapports PDF automatiques hebdomadaires + +--- + +## Auteur + +**William MERI** — Conçu avec QueueMed v1.0.0 +© 2026 QueueMed. Tous droits réservés. diff --git a/docs_ref/schema.ts b/docs_ref/schema.ts new file mode 100644 index 0000000..a3b9266 --- /dev/null +++ b/docs_ref/schema.ts @@ -0,0 +1,127 @@ +import { + int, + mysqlEnum, + mysqlTable, + text, + timestamp, + varchar, + boolean, + bigint, + float, + json, +} from "drizzle-orm/mysql-core"; + +// ─── Users (médecins) ──────────────────────────────────────────────────────── +export const users = mysqlTable("users", { + id: int("id").autoincrement().primaryKey(), + openId: varchar("openId", { length: 64 }).notNull().unique(), + name: text("name"), + email: varchar("email", { length: 320 }), + loginMethod: varchar("loginMethod", { length: 64 }), + role: mysqlEnum("role", ["user", "admin"]).default("user").notNull(), + createdAt: timestamp("createdAt").defaultNow().notNull(), + updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), + lastSignedIn: timestamp("lastSignedIn").defaultNow().notNull(), +}); + +export type User = typeof users.$inferSelect; +export type InsertUser = typeof users.$inferInsert; + +// ─── Subscriptions ─────────────────────────────────────────────────────────── +export const subscriptions = mysqlTable("subscriptions", { + id: int("id").autoincrement().primaryKey(), + userId: int("userId").notNull(), + stripeCustomerId: varchar("stripeCustomerId", { length: 128 }), + stripeSubscriptionId: varchar("stripeSubscriptionId", { length: 128 }), + stripePriceId: varchar("stripePriceId", { length: 128 }), + plan: mysqlEnum("plan", ["trial", "basic", "pro"]).default("trial").notNull(), + status: mysqlEnum("status", ["trialing", "active", "past_due", "canceled", "expired"]).default("trialing").notNull(), + trialStartedAt: timestamp("trialStartedAt").defaultNow().notNull(), + trialEndsAt: timestamp("trialEndsAt").notNull(), + currentPeriodStart: timestamp("currentPeriodStart"), + currentPeriodEnd: timestamp("currentPeriodEnd"), + canceledAt: timestamp("canceledAt"), + createdAt: timestamp("createdAt").defaultNow().notNull(), + updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), +}); + +export type Subscription = typeof subscriptions.$inferSelect; +export type InsertSubscription = typeof subscriptions.$inferInsert; + +// ─── Clinics (cabinets médicaux) ───────────────────────────────────────────── +export const clinics = mysqlTable("clinics", { + id: int("id").autoincrement().primaryKey(), + userId: int("userId").notNull(), + name: varchar("name", { length: 255 }).notNull(), + address: text("address"), + phone: varchar("phone", { length: 32 }), + color: varchar("color", { length: 16 }).default("#0d9488"), + isActive: boolean("isActive").default(true).notNull(), + // QR code token rotatif anti-triche + qrToken: varchar("qrToken", { length: 64 }).notNull(), + qrTokenExpiresAt: timestamp("qrTokenExpiresAt"), + qrRotationMinutes: int("qrRotationMinutes").default(30), + // Paramètres file d'attente + avgConsultationMinutes: int("avgConsultationMinutes").default(15), + maxQueueSize: int("maxQueueSize").default(50), + isQueueOpen: boolean("isQueueOpen").default(false).notNull(), + currentTicketNumber: int("currentTicketNumber").default(0).notNull(), + createdAt: timestamp("createdAt").defaultNow().notNull(), + updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), +}); + +export type Clinic = typeof clinics.$inferSelect; +export type InsertClinic = typeof clinics.$inferInsert; + +// ─── Queue Entries (patients en file) ──────────────────────────────────────── +export const queueEntries = mysqlTable("queue_entries", { + id: int("id").autoincrement().primaryKey(), + clinicId: int("clinicId").notNull(), + ticketNumber: int("ticketNumber").notNull(), + // Identifiant de session anonyme du patient + patientToken: varchar("patientToken", { length: 64 }).notNull(), + patientName: varchar("patientName", { length: 128 }), + patientPhone: varchar("patientPhone", { length: 32 }), + status: mysqlEnum("status", ["waiting", "called", "in_consultation", "done", "absent", "canceled"]) + .default("waiting") + .notNull(), + position: int("position").notNull(), + joinedAt: timestamp("joinedAt").defaultNow().notNull(), + calledAt: timestamp("calledAt"), + consultationStartAt: timestamp("consultationStartAt"), + consultationEndAt: timestamp("consultationEndAt"), + estimatedWaitMinutes: int("estimatedWaitMinutes"), + notificationSent: boolean("notificationSent").default(false).notNull(), + // Pour l'impression de ticket + isPrinted: boolean("isPrinted").default(false).notNull(), + createdAt: timestamp("createdAt").defaultNow().notNull(), + updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), +}); + +export type QueueEntry = typeof queueEntries.$inferSelect; +export type InsertQueueEntry = typeof queueEntries.$inferInsert; + +// ─── Analytics Events ───────────────────────────────────────────────────────── +export const analyticsEvents = mysqlTable("analytics_events", { + id: int("id").autoincrement().primaryKey(), + clinicId: int("clinicId").notNull(), + eventType: mysqlEnum("eventType", [ + "patient_joined", + "patient_called", + "patient_done", + "patient_absent", + "queue_opened", + "queue_closed", + ]).notNull(), + ticketNumber: int("ticketNumber"), + waitMinutes: int("waitMinutes"), + consultationMinutes: int("consultationMinutes"), + queueSizeAtEvent: int("queueSizeAtEvent"), + hourOfDay: int("hourOfDay"), + dayOfWeek: int("dayOfWeek"), + metadata: json("metadata"), + createdAt: timestamp("createdAt").defaultNow().notNull(), +}); + +export type AnalyticsEvent = typeof analyticsEvents.$inferSelect; +export type InsertAnalyticsEvent = typeof analyticsEvents.$inferInsert; diff --git a/src_ref/pages/Dashboard.tsx b/src_ref/pages/Dashboard.tsx new file mode 100644 index 0000000..5f80840 --- /dev/null +++ b/src_ref/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_ref/pages/Help.tsx b/src_ref/pages/Help.tsx new file mode 100644 index 0000000..8b512fb --- /dev/null +++ b/src_ref/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_ref/pages/Onboarding.tsx b/src_ref/pages/Onboarding.tsx new file mode 100644 index 0000000..8b0b74b --- /dev/null +++ b/src_ref/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_ref/pages/QrPoster.tsx b/src_ref/pages/QrPoster.tsx new file mode 100644 index 0000000..e141bf7 --- /dev/null +++ b/src_ref/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_ref/pages/QueueManagement.tsx b/src_ref/pages/QueueManagement.tsx new file mode 100644 index 0000000..17687c9 --- /dev/null +++ b/src_ref/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_ref/server/onboarding.test.ts b/src_ref/server/onboarding.test.ts new file mode 100644 index 0000000..397ab19 --- /dev/null +++ b/src_ref/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); + }); +});