init: project skeleton with reference files and CLAUDE.md

This commit is contained in:
Hermes 2026-04-25 03:34:16 +00:00
commit d24d0c3e70
11 changed files with 2121 additions and 0 deletions

106
CLAUDE.md Normal file
View file

@ -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

106
MANUS_HANDOFF.md Normal file
View file

@ -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/

252
MODE_OPERATOIRE.md Normal file
View file

@ -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*

118
README.md Normal file
View file

@ -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.

127
docs_ref/schema.ts Normal file
View file

@ -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;

200
src_ref/pages/Dashboard.tsx Normal file
View 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_ref/pages/Help.tsx Normal file
View 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>
);
}

View 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_ref/pages/QrPoster.tsx Normal file
View 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>
);
}

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

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