diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..710ca71 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,27 @@ +node_modules +dist +.git +.github +.gitignore +.env +.env.* +!.env.example +src_ref +docs_ref +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +.DS_Store +.idea +.vscode +coverage +.turbo +.next +*.md +!README.md +backend-prompt.md +MANUS_HANDOFF.md +MODE_OPERATOIRE.md +CLAUDE.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..56156dd --- /dev/null +++ b/.env.example @@ -0,0 +1,61 @@ +# ─── Database ─────────────────────────────────────────────────────────────── +# Local dev (host MySQL): +# DATABASE_URL=mysql://queuemed:queuemed@localhost:3306/queuemed +# Docker compose (uses the "db" service): +# DATABASE_URL=mysql://queuemed:queuemed@db:3306/queuemed +DATABASE_URL=mysql://queuemed:queuemed@localhost:3306/queuemed + +# ─── Auth ─────────────────────────────────────────────────────────────────── +# REQUIRED. Must be at least 32 characters of high-entropy random data. +# Generate one with: openssl rand -hex 64 +# The server refuses to start if this is missing or too short. +JWT_SECRET=replace_me_with_openssl_rand_hex_64_output + +# ─── Server ───────────────────────────────────────────────────────────────── +PORT=5000 +NODE_ENV=development + +# Public URL used to build QR code links (e.g. https://queuemed.example.com). +# In production this should match the public origin allowed by CORS. +PUBLIC_BASE_URL= + +# ─── Stripe ───────────────────────────────────────────────────────────────── +# All Stripe vars are OPTIONAL. If STRIPE_SECRET_KEY is not set, the app still +# runs and the subscription UI shows a friendly "not configured" notice. +STRIPE_SECRET_KEY= +STRIPE_WEBHOOK_SECRET= +STRIPE_BASIC_PRICE_ID= +STRIPE_PRO_PRICE_ID= + +# Client-side price IDs — must be exposed at build time via VITE_ prefix. +# Same values as STRIPE_BASIC_PRICE_ID / STRIPE_PRO_PRICE_ID. +VITE_STRIPE_BASIC_PRICE_ID= +VITE_STRIPE_PRO_PRICE_ID= + +# ─── WhatsApp (Baileys) ───────────────────────────────────────────────────── +# Persistent directory used to store Baileys auth credentials per clinic. +# Must live on a Docker volume in production so sessions survive restarts. +WHATSAPP_SESSION_DIR=/app/data/whatsapp-sessions + +# ─── Twilio SMS ───────────────────────────────────────────────────────────── +# All Twilio vars are OPTIONAL. If any is missing, SMS sending is disabled +# and the app logs a warning instead of failing. Each clinic also has a +# `smsEnabled` opt-in flag; SMS is never sent without both. +TWILIO_ACCOUNT_SID= +TWILIO_AUTH_TOKEN= +TWILIO_PHONE_NUMBER= + +# ─── Docker compose only ──────────────────────────────────────────────────── +MYSQL_ROOT_PASSWORD=replace_me_with_a_strong_password +MYSQL_DATABASE=queuemed +MYSQL_USER=queuemed +MYSQL_PASSWORD=replace_me_with_a_strong_password +MYSQL_PORT=3306 +APP_PORT=5000 + +# ─── Backups (used by scripts/backup-db.sh) ───────────────────────────────── +# Inside the `app` container these point at the `db` service. +# Override only if running the script outside docker compose. +# MYSQL_HOST=db +# BACKUP_DIR=/app/data/backups +# BACKUP_KEEP=7 diff --git a/.gitignore b/.gitignore index e69de29..4534cef 100644 --- a/.gitignore +++ b/.gitignore @@ -0,0 +1,17 @@ +node_modules/ +dist/ +.env +.env.docker +.env.local +*.log +.DS_Store +Thumbs.db +*.md +!README.md +!docs/ +.vscode/ +.idea/ +*.swp +*.swo +*~ +.claude/ diff --git a/AGENT_CONTEXT.md b/AGENT_CONTEXT.md deleted file mode 100644 index e69de29..0000000 diff --git a/AUTHORS.md b/AUTHORS.md deleted file mode 100644 index e69de29..0000000 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/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4372d95 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,54 @@ +# syntax=docker/dockerfile:1.7 + +# ─── Stage 1 — install deps ───────────────────────────────────────────────── +FROM node:22-alpine AS deps +WORKDIR /app +RUN apk add --no-cache git python3 make g++ +RUN corepack enable && corepack prepare pnpm@9.15.0 --activate +COPY package.json pnpm-lock.yaml* ./ +RUN pnpm install --frozen-lockfile || pnpm install + +# ─── Stage 2 — build client + server ───────────────────────────────────────── +FROM node:22-alpine AS builder +WORKDIR /app +RUN apk add --no-cache git +RUN corepack enable && corepack prepare pnpm@9.15.0 --activate +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN pnpm build + +# ─── Stage 3 — runtime ────────────────────────────────────────────────────── +FROM node:22-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production \ + PORT=5000 + +RUN corepack enable && corepack prepare pnpm@9.15.0 --activate + +# Copy production deps + built assets +COPY --from=deps /app/node_modules ./node_modules +COPY --from=builder /app/dist ./dist +COPY package.json tsconfig.json drizzle.config.ts ./ +COPY server ./server +COPY shared ./shared + +# Persistent app data directory (WhatsApp sessions, DB backups, …). +# In production this should be backed by a Docker named volume. +RUN mkdir -p /app/data/whatsapp-sessions /app/data/backups + +# Bundle the operational scripts (DB backup, etc.) +COPY scripts ./scripts +RUN chmod +x ./scripts/*.sh || true + +# Tools used by the backup script +RUN apk add --no-cache mysql-client + +RUN addgroup -S app && adduser -S app -G app && chown -R app:app /app +USER app + +EXPOSE 5000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD wget -qO- http://127.0.0.1:5000/api/health || exit 1 + +CMD ["pnpm", "start"] diff --git a/MODE_OPERATOIRE_QueueMed.pdf b/MODE_OPERATOIRE_QueueMed.pdf deleted file mode 100644 index d7021cf..0000000 Binary files a/MODE_OPERATOIRE_QueueMed.pdf and /dev/null differ diff --git a/ROADMAP.html b/ROADMAP.html deleted file mode 100644 index e69de29..0000000 diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..f9cfe11 --- /dev/null +++ b/client/index.html @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + QueueMed — Salle d'attente virtuelle + + + + + +
+ + + diff --git a/client/public/favicon.svg b/client/public/favicon.svg new file mode 100644 index 0000000..fce6a66 --- /dev/null +++ b/client/public/favicon.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/client/public/icon-192x192.svg b/client/public/icon-192x192.svg new file mode 100644 index 0000000..9c82f55 --- /dev/null +++ b/client/public/icon-192x192.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/client/public/icon-512x512.svg b/client/public/icon-512x512.svg new file mode 100644 index 0000000..7955f12 --- /dev/null +++ b/client/public/icon-512x512.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/client/src/App.tsx b/client/src/App.tsx new file mode 100644 index 0000000..018b951 --- /dev/null +++ b/client/src/App.tsx @@ -0,0 +1,140 @@ +import { Route, Switch, Redirect } from "wouter"; +import { HelmetProvider } from "react-helmet-async"; +import { Toaster } from "@/components/ui/toast"; +import { useAuth } from "@/_core/hooks/useAuth"; +import Layout from "@/components/Layout"; +import { Loader2 } from "lucide-react"; + +import Home from "@/pages/Home"; +import Login from "@/pages/Login"; +import ForgotPassword from "@/pages/ForgotPassword"; +import ResetPassword from "@/pages/ResetPassword"; +import Dashboard from "@/pages/Dashboard"; +import DoctorClinics from "@/pages/DoctorClinics"; +import QueueManagement from "@/pages/QueueManagement"; +import Analytics from "@/pages/Analytics"; +import PatientQueue from "@/pages/PatientQueue"; +import QrJoin from "@/pages/QrJoin"; +import DisplayScreen from "@/pages/DisplayScreen"; +import SubscriptionPage from "@/pages/SubscriptionPage"; +import PrintTicket from "@/pages/PrintTicket"; +import Onboarding from "@/pages/Onboarding"; +import Help from "@/pages/Help"; +import QrPoster from "@/pages/QrPoster"; +import ClinicSettings from "@/pages/ClinicSettings"; +import ConsultationHistory from "@/pages/ConsultationHistory"; +import WhatsAppSetup from "@/pages/WhatsAppSetup"; +import SubscriptionBlocked from "@/pages/SubscriptionBlocked"; +import SubscriptionSuccess from "@/pages/SubscriptionSuccess"; +import SubscriptionCancel from "@/pages/SubscriptionCancel"; +import AdminPanel from "@/pages/AdminPanel"; +import AdminSettings from "@/pages/AdminSettings"; + +function ProtectedRoute({ children }: { children: React.ReactNode }) { + const { isAuthenticated, loading } = useAuth(); + + if (loading) { + return ( +
+ +
+ ); + } + if (!isAuthenticated) return ; + return {children}; +} + +export default function App() { + return ( + + + + {/* Public marketing & auth */} + + + + + + + {/* Public patient/display routes */} + + + + + + {/* Authenticated routes (wrapped in Layout) */} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* Admin */} + + + + + + + + {/* Fallback */} + + + + + + ); +} + +function NotFound() { + return ( +
+
+
404
+

Cette page n'existe pas ou a été déplacée.

+ + Retour à l'accueil + +
+
+ ); +} diff --git a/client/src/_core/hooks/useAuth.ts b/client/src/_core/hooks/useAuth.ts new file mode 100644 index 0000000..8811715 --- /dev/null +++ b/client/src/_core/hooks/useAuth.ts @@ -0,0 +1,66 @@ +import { useCallback } from "react"; +import { useLocation } from "wouter"; +import { useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { trpc } from "@/lib/trpc"; + +export function useAuth() { + const [, navigate] = useLocation(); + const queryClient = useQueryClient(); + + const meQuery = trpc.auth.me.useQuery(undefined, { + retry: false, + staleTime: 60_000, + }); + + const loginMutation = trpc.auth.login.useMutation({ + onSuccess: async () => { + await meQuery.refetch(); + await queryClient.invalidateQueries(); + toast.success("Connecté avec succès"); + }, + onError: (e) => toast.error(e.message), + }); + + const registerMutation = trpc.auth.register.useMutation({ + onSuccess: async () => { + await meQuery.refetch(); + await queryClient.invalidateQueries(); + toast.success("Compte créé — bienvenue sur QueueMed !"); + }, + onError: (e) => toast.error(e.message), + }); + + const logoutMutation = trpc.auth.logout.useMutation({ + onSuccess: async () => { + await queryClient.clear(); + await meQuery.refetch(); + navigate("/"); + toast.success("Déconnecté"); + }, + }); + + const login = useCallback( + (email: string, password: string) => loginMutation.mutateAsync({ email, password }), + [loginMutation] + ); + + const register = useCallback( + (email: string, password: string, name?: string) => + registerMutation.mutateAsync({ email, password, name }), + [registerMutation] + ); + + const logout = useCallback(() => logoutMutation.mutate(), [logoutMutation]); + + return { + user: meQuery.data ?? null, + isAuthenticated: !!meQuery.data, + loading: meQuery.isLoading, + login, + register, + logout, + isLoggingIn: loginMutation.isPending, + isRegistering: registerMutation.isPending, + }; +} diff --git a/client/src/components/CountryCodeManager.tsx b/client/src/components/CountryCodeManager.tsx new file mode 100644 index 0000000..2d5f805 --- /dev/null +++ b/client/src/components/CountryCodeManager.tsx @@ -0,0 +1,291 @@ +import { useState, useMemo } from "react"; +import { trpc } from "@/lib/trpc"; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { toast } from "sonner"; +import { + Globe, + Search, + CheckCircle, + Circle, + Loader2, + ChevronDown, + ChevronUp, + ToggleLeft, + ToggleRight, +} from "lucide-react"; + +type CountryCode = { + id: number; + code: string; + dialCode: string; + nameFr: string; + flag: string; + enabled: boolean; + sortOrder: number; +}; + +// Groupes régionaux pour organiser l'affichage +const REGION_GROUPS: { label: string; codes: string[] }[] = [ + { + label: "France & DOM-TOM", + codes: ["FR", "GP", "MQ", "RE", "GF", "PM", "YT", "NC", "PF", "WF"], + }, + { + label: "Europe", + codes: ["BE", "CH", "LU", "MC", "DE", "ES", "IT", "PT", "GB", "NL", "PL", "SE", "NO", "DK", "FI", "AT", "GR", "RO", "HU", "CZ", "TR"], + }, + { + label: "Afrique francophone", + codes: ["MA", "DZ", "TN", "SN", "CI", "CM", "CD", "CG", "MG", "ML", "BF", "NE", "TD", "GN", "BJ", "TG", "MR", "GA", "GQ", "CF", "KM", "DJ", "MU", "SC", "EG"], + }, + { + label: "Amériques", + codes: ["US", "CA", "MX", "BR", "AR", "CO", "CL", "PE", "VE", "EC", "BO", "PY", "UY", "HT"], + }, + { + label: "Asie & Océanie", + codes: ["IN", "CN", "JP", "AU", "LB"], + }, +]; + +export default function CountryCodeManager() { + const utils = trpc.useUtils(); + const [search, setSearch] = useState(""); + const [expandedGroups, setExpandedGroups] = useState>(new Set(["France & DOM-TOM"])); + const [pendingToggles, setPendingToggles] = useState>(new Set()); + + const { data: allCodes = [], isLoading } = trpc.whatsapp.listAllCountryCodes.useQuery(); + + const toggleMut = trpc.whatsapp.toggleCountryCode.useMutation({ + onMutate: ({ code }) => { + setPendingToggles((prev) => new Set(prev).add(code)); + }, + onSuccess: (data) => { + setPendingToggles((prev) => { + const next = new Set(prev); + next.delete(data.code); + return next; + }); + utils.whatsapp.listAllCountryCodes.invalidate(); + utils.whatsapp.listCountryCodes.invalidate(); + toast.success(data.enabled ? "Indicatif activé" : "Indicatif désactivé"); + }, + onError: (err, { code }) => { + setPendingToggles((prev) => { + const next = new Set(prev); + next.delete(code); + return next; + }); + toast.error(err.message); + }, + }); + + const bulkMut = trpc.whatsapp.bulkToggleCountryCodes.useMutation({ + onSuccess: (data, vars) => { + utils.whatsapp.listAllCountryCodes.invalidate(); + utils.whatsapp.listCountryCodes.invalidate(); + toast.success(`${data.count} indicatifs ${vars.enabled ? "activés" : "désactivés"}`); + }, + onError: (err) => toast.error(err.message), + }); + + // Map code → entry for quick lookup + const codeMap = useMemo(() => { + const m = new Map(); + allCodes.forEach((c) => m.set(c.code, c)); + return m; + }, [allCodes]); + + // Filtered list for search mode + const searchResults = useMemo(() => { + if (!search.trim()) return []; + const q = search.toLowerCase(); + return allCodes.filter( + (c) => + c.nameFr.toLowerCase().includes(q) || + c.code.toLowerCase().includes(q) || + c.dialCode.includes(q) + ); + }, [allCodes, search]); + + const toggleGroup = (label: string) => { + setExpandedGroups((prev) => { + const next = new Set(prev); + if (next.has(label)) next.delete(label); + else next.add(label); + return next; + }); + }; + + const handleToggle = (code: string, currentEnabled: boolean) => { + toggleMut.mutate({ code, enabled: !currentEnabled }); + }; + + const handleBulkGroup = (codes: string[], enabled: boolean) => { + const validCodes = codes.filter((c) => codeMap.has(c)); + bulkMut.mutate({ codes: validCodes, enabled }); + }; + + const enabledCount = allCodes.filter((c) => c.enabled).length; + + if (isLoading) { + return ( +
+ +
+ ); + } + + const renderCountryRow = (c: CountryCode) => { + const isPending = pendingToggles.has(c.code); + return ( +
+
+ {c.flag} +
+ {c.nameFr} + +{c.dialCode} · {c.code} +
+
+ +
+ ); + }; + + return ( + + +
+
+ + + Indicatifs pays disponibles + + + Choisissez les pays affichés aux patients dans le formulaire WhatsApp.{" "} + + {enabledCount} activé{enabledCount > 1 ? "s" : ""} + + +
+
+ + {/* Search */} +
+ + setSearch(e.target.value)} + className="pl-9 bg-background/50" + /> +
+
+ + + {/* Search results */} + {search.trim() ? ( +
+ {searchResults.length === 0 ? ( +

Aucun résultat pour « {search} »

+ ) : ( + searchResults.map((c) => renderCountryRow(c)) + )} +
+ ) : ( + /* Grouped view */ + REGION_GROUPS.map((group) => { + const groupCodes = group.codes + .map((code) => codeMap.get(code)) + .filter(Boolean) as CountryCode[]; + if (groupCodes.length === 0) return null; + + const isExpanded = expandedGroups.has(group.label); + const groupEnabled = groupCodes.filter((c) => c.enabled).length; + + return ( +
+ {/* Group header */} +
toggleGroup(group.label)} + > +
+ {group.label} + + {groupEnabled}/{groupCodes.length} + +
+
+ {/* Bulk actions */} + + + {isExpanded ? ( + + ) : ( + + )} +
+
+ + {/* Group rows */} + {isExpanded && ( +
+ {groupCodes.map((c) => renderCountryRow(c))} +
+ )} +
+ ); + }) + )} +
+
+ ); +} diff --git a/client/src/components/Layout.tsx b/client/src/components/Layout.tsx new file mode 100644 index 0000000..6042801 --- /dev/null +++ b/client/src/components/Layout.tsx @@ -0,0 +1,210 @@ +import { useState } from "react"; +import { Link, useLocation } from "wouter"; +import { useTranslation } from "react-i18next"; +import { + LayoutDashboard, Building2, BarChart3, CreditCard, + HelpCircle, LogOut, Stethoscope, Menu, X, Shield, Settings, +} from "lucide-react"; +import { useAuth } from "@/_core/hooks/useAuth"; +import { cn } from "@/lib/utils"; + +function LanguageSwitcher({ className = "" }: { className?: string }) { + const { i18n } = useTranslation(); + const current = i18n.resolvedLanguage ?? i18n.language ?? "fr"; + const change = (lng: "fr" | "en") => i18n.changeLanguage(lng); + return ( +
+ + +
+ ); +} + +export default function Layout({ children }: { children: React.ReactNode }) { + const { t } = useTranslation(); + const [location] = useLocation(); + const { user, logout } = useAuth(); + const [mobileOpen, setMobileOpen] = useState(false); + + const NAV = [ + { href: "/dashboard", label: t("nav.dashboard"), icon: LayoutDashboard }, + { href: "/dashboard/clinics", label: t("nav.clinics"), icon: Building2 }, + { href: "/dashboard/analytics", label: t("nav.analytics"), icon: BarChart3 }, + { href: "/dashboard/subscription", label: t("nav.subscription"), icon: CreditCard }, + { href: "/help", label: t("nav.help"), icon: HelpCircle }, + ...(user?.role === "admin" + ? [{ href: "/admin", label: "Admin", icon: Shield }, { href: "/admin/settings", label: "Param\u00e8tres", icon: Settings }] + : []), + ]; + + const isActive = (href: string) => + href === "/dashboard" + ? location === "/dashboard" + : location === href || location.startsWith(href + "/"); + + return ( +
+ {/* ─── Sidebar (desktop) ──────────────────────────────────────────── */} + + + {/* ─── Main column ───────────────────────────────────────────────── */} +
+ {/* Top bar (mobile) */} +
+ + +
+ +
+ QueueMed +
+ +
+ + +
+
+ + {/* Mobile drawer */} + {mobileOpen && ( +
+
setMobileOpen(false)} + /> +
+
+
+
+ +
+ QueueMed +
+ +
+ +
+
+
{user?.name ?? user?.email ?? "—"}
+
+ +
+
+
+ )} + +
{children}
+
+
+ ); +} diff --git a/client/src/components/PhoneDialCodePicker.tsx b/client/src/components/PhoneDialCodePicker.tsx new file mode 100644 index 0000000..f5a8a94 --- /dev/null +++ b/client/src/components/PhoneDialCodePicker.tsx @@ -0,0 +1,242 @@ +/** + * PhoneDialCodePicker + * Sélecteur d'indicatif pays + champ numéro local pour les notifications WhatsApp. + * - Affiche uniquement les pays activés par l'admin (trpc.whatsapp.listCountryCodes) + * - Valide la longueur et le format du numéro selon les règles par pays + * - Expose onValidationChange pour que le parent puisse bloquer la soumission + */ +import { useState, useRef, useEffect, useMemo } from "react"; +import { trpc } from "@/lib/trpc"; +import { Loader2, ChevronDown, Search, AlertCircle, CheckCircle2 } from "lucide-react"; +import { + validateLocalPhone, + getPhonePlaceholder, + getPhoneHint, +} from "@shared/phoneValidation"; + +type CountryCode = { + id: number; + code: string; + dialCode: string; + nameFr: string; + flag: string; + enabled: boolean; + sortOrder: number; +}; + +type Props = { + /** Numéro complet au format international sans + (ex: "33612345678") */ + value: string; + onChange: (fullNumber: string) => void; + /** Appelé avec null si valide, message d'erreur sinon */ + onValidationChange?: (error: string | null) => void; + className?: string; +}; + +export default function PhoneDialCodePicker({ + value, + onChange, + onValidationChange, + className = "", +}: Props) { + const { data: countries = [], isLoading } = trpc.whatsapp.listCountryCodes.useQuery(); + + const [selectedCode, setSelectedCode] = useState(null); + const [localNumber, setLocalNumber] = useState(""); + const [touched, setTouched] = useState(false); + const [dropdownOpen, setDropdownOpen] = useState(false); + const [search, setSearch] = useState(""); + const dropdownRef = useRef(null); + + // Auto-select first country once loaded + useEffect(() => { + if (countries.length > 0 && !selectedCode) { + setSelectedCode(countries[0]); + } + }, [countries, selectedCode]); + + // Sync outgoing value + validation + useEffect(() => { + if (!selectedCode) return; + const cleaned = localNumber.replace(/\D/g, "").replace(/^0+/, ""); + const full = cleaned ? `${selectedCode.dialCode}${cleaned}` : ""; + onChange(full); + + if (onValidationChange) { + if (!cleaned) { + // Empty = not yet filled, no error shown until touched + onValidationChange(touched ? "Veuillez saisir votre numéro." : null); + } else { + const err = validateLocalPhone(selectedCode.dialCode, cleaned); + onValidationChange(err); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedCode, localNumber, touched]); + + // Close dropdown on outside click + useEffect(() => { + const handler = (e: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { + setDropdownOpen(false); + setSearch(""); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, []); + + const filtered = useMemo(() => { + if (!search.trim()) return countries; + const q = search.toLowerCase(); + return countries.filter( + (c) => + c.nameFr.toLowerCase().includes(q) || + c.code.toLowerCase().includes(q) || + c.dialCode.includes(q) + ); + }, [countries, search]); + + // Compute validation state for display + const cleaned = localNumber.replace(/\D/g, "").replace(/^0+/, ""); + const validationError = selectedCode && cleaned + ? validateLocalPhone(selectedCode.dialCode, cleaned) + : null; + const isValid = cleaned.length > 0 && validationError === null; + const showError = touched && cleaned.length > 0 && validationError !== null; + + const placeholder = selectedCode ? getPhonePlaceholder(selectedCode.dialCode) : "123456789"; + const hint = selectedCode ? getPhoneHint(selectedCode.dialCode) : ""; + + if (isLoading) { + return ( +
+ + Chargement… +
+ ); + } + + if (countries.length === 0) { + return ( + setLocalNumber(e.target.value)} + placeholder="Numéro international (ex: 33612345678)" + className={`w-full bg-background/50 border border-emerald-500/40 rounded-xl px-4 py-2.5 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-emerald-500/70 ${className}`} + /> + ); + } + + return ( +
+
+ {/* Dial code selector */} + + + {/* Local number input */} +
+ setLocalNumber(e.target.value)} + onBlur={() => setTouched(true)} + placeholder={placeholder} + className={`w-full bg-background/50 border rounded-xl px-4 py-2.5 pr-9 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none transition-colors ${ + showError + ? "border-red-500/60 focus:border-red-500/80" + : isValid + ? "border-emerald-500/60 focus:border-emerald-500/80" + : "border-emerald-500/40 focus:border-emerald-500/70" + }`} + /> + {/* Validation icon */} + {cleaned.length > 0 && ( +
+ {isValid ? ( + + ) : ( + + )} +
+ )} +
+ + {/* Dropdown */} + {dropdownOpen && ( +
+ {/* Search */} +
+
+ + setSearch(e.target.value)} + placeholder="Rechercher…" + className="w-full pl-8 pr-3 py-1.5 text-sm bg-background/50 border border-border/50 rounded-lg text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary/60" + /> +
+
+ + {/* Country list */} +
+ {filtered.length === 0 ? ( +

Aucun résultat

+ ) : ( + filtered.map((c) => ( + + )) + )} +
+
+ )} +
+ + {/* Validation message or hint */} + {showError ? ( +

+ + {validationError} +

+ ) : ( +

{hint}

+ )} +
+ ); +} diff --git a/client/src/components/PractitionerManager.tsx b/client/src/components/PractitionerManager.tsx new file mode 100644 index 0000000..f9bf950 --- /dev/null +++ b/client/src/components/PractitionerManager.tsx @@ -0,0 +1,233 @@ +import { useState } from "react"; +import { Loader2, Plus, Trash2, UserPlus, Users } from "lucide-react"; +import { trpc } from "@/lib/trpc"; +import { Button } from "@/components/ui/button"; +import { toast } from "sonner"; + +const PRESET_COLORS = [ + "#10b981", // emerald + "#06b6d4", // cyan + "#0d9488", // teal + "#8b5cf6", // violet + "#f97316", // orange + "#ec4899", // pink + "#3b82f6", // blue + "#eab308", // yellow +]; + +export default function PractitionerManager({ clinicId }: { clinicId: number }) { + const utils = trpc.useUtils(); + const membersQuery = trpc.clinic.listMembers.useQuery( + { clinicId }, + { enabled: clinicId > 0 } + ); + + const [adding, setAdding] = useState(false); + const [email, setEmail] = useState(""); + const [displayName, setDisplayName] = useState(""); + const [color, setColor] = useState(PRESET_COLORS[0]); + + const addMember = trpc.clinic.addMember.useMutation({ + onSuccess: () => { + toast.success("Praticien ajouté"); + utils.clinic.listMembers.invalidate({ clinicId }); + utils.clinic.listMembersPublic.invalidate({ clinicId }); + setEmail(""); + setDisplayName(""); + setColor(PRESET_COLORS[0]); + setAdding(false); + }, + onError: (e) => toast.error(e.message), + }); + + const removeMember = trpc.clinic.removeMember.useMutation({ + onSuccess: () => { + toast.success("Praticien retiré"); + utils.clinic.listMembers.invalidate({ clinicId }); + utils.clinic.listMembersPublic.invalidate({ clinicId }); + }, + onError: (e) => toast.error(e.message), + }); + + const updateMember = trpc.clinic.updateMember.useMutation({ + onSuccess: () => { + utils.clinic.listMembers.invalidate({ clinicId }); + utils.clinic.listMembersPublic.invalidate({ clinicId }); + }, + onError: (e) => toast.error(e.message), + }); + + const handleAdd = () => { + if (!email.trim()) { + toast.error("Email requis"); + return; + } + addMember.mutate({ + clinicId, + email: email.trim(), + displayName: displayName.trim() || undefined, + color, + }); + }; + + const members = membersQuery.data ?? []; + + return ( +
+
+
+
+ +
+
+

Praticiens du cabinet

+

+ Multi-médecins · couleur d'identification +

+
+
+ +
+ + {adding && ( +
+
+ + setEmail(e.target.value)} + placeholder="docteur@example.com" + className="w-full px-3 py-2 rounded-lg border border-slate-200 bg-white text-sm focus:outline-none focus:border-emerald-400" + /> +

+ Le praticien doit déjà avoir un compte QueueMed. +

+
+
+ + setDisplayName(e.target.value)} + placeholder="Dr. Martin" + className="w-full px-3 py-2 rounded-lg border border-slate-200 bg-white text-sm focus:outline-none focus:border-emerald-400" + /> +
+
+ +
+ {PRESET_COLORS.map((c) => ( +
+
+ +
+ )} + + {membersQuery.isLoading ? ( +
+ +
+ ) : members.length === 0 ? ( +

+ Aucun praticien associé. Ajoutez-en un pour activer l'attribution. +

+ ) : ( +
    + {members.map((m) => ( +
  • + + updateMember.mutate({ + clinicId, + memberId: m.id, + color: e.target.value, + }) + } + className="w-9 h-9 rounded-lg border border-slate-200 cursor-pointer flex-shrink-0" + aria-label="Modifier la couleur" + /> +
    +
    + {m.displayName ?? m.name ?? m.email ?? "—"} +
    +
    + {m.email ?? "—"} · {m.role} +
    +
    + +
  • + ))} +
+ )} +
+ ); +} diff --git a/client/src/components/WhatsAppTemplateEditor.tsx b/client/src/components/WhatsAppTemplateEditor.tsx new file mode 100644 index 0000000..63920bd --- /dev/null +++ b/client/src/components/WhatsAppTemplateEditor.tsx @@ -0,0 +1,352 @@ +import { useState, useRef, useCallback, useMemo, useEffect } from "react"; +import { trpc } from "@/lib/trpc"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { Textarea } from "@/components/ui/textarea"; +import { Badge } from "@/components/ui/badge"; +import { toast } from "sonner"; +import { + DEFAULT_TEMPLATES, + TEMPLATE_VARIABLES, + TEMPLATE_LABELS, + SAMPLE_CONTEXT, + interpolateTemplate, + type TemplateType, +} from "../../../shared/whatsappTemplates"; +import { Save, RotateCcw, Eye, EyeOff, MessageSquare, Sparkles } from "lucide-react"; + +interface WhatsAppTemplateEditorProps { + clinicId: number; +} + +export default function WhatsAppTemplateEditor({ clinicId }: WhatsAppTemplateEditorProps) { + const { data: templates, isLoading, refetch } = trpc.clinicSettings.getTemplates.useQuery({ clinicId }); + const updateMutation = trpc.clinicSettings.updateTemplates.useMutation({ + onSuccess: () => { + toast.success("Templates sauvegardés", { description: "Les modèles de messages ont été mis à jour." }); + refetch(); + }, + onError: (err) => { + toast.error("Erreur", { description: err.message }); + }, + }); + + const templateTypes: TemplateType[] = ["joined", "soon", "called", "withdrawn"]; + + // Local state for each template + const [localTemplates, setLocalTemplates] = useState>({ + joined: "", + soon: "", + called: "", + withdrawn: "", + }); + + // Track which templates have been modified + const [modified, setModified] = useState>({ + joined: false, + soon: false, + called: false, + withdrawn: false, + }); + + // Preview toggle per template + const [showPreview, setShowPreview] = useState>({ + joined: false, + soon: false, + called: false, + withdrawn: false, + }); + + // Active tab + const [activeTab, setActiveTab] = useState("joined"); + + // Refs for textareas + const textareaRefs = useRef>({ + joined: null, + soon: null, + called: null, + withdrawn: null, + }); + + // Initialize local state from server data + useEffect(() => { + if (templates) { + setLocalTemplates({ + joined: templates.joined ?? DEFAULT_TEMPLATES.joined, + soon: templates.soon ?? DEFAULT_TEMPLATES.soon, + called: templates.called ?? DEFAULT_TEMPLATES.called, + withdrawn: templates.withdrawn ?? DEFAULT_TEMPLATES.withdrawn, + }); + setModified({ joined: false, soon: false, called: false, withdrawn: false }); + } + }, [templates]); + + const handleChange = useCallback((type: TemplateType, value: string) => { + setLocalTemplates((prev) => ({ ...prev, [type]: value })); + setModified((prev) => ({ ...prev, [type]: true })); + }, []); + + const insertVariable = useCallback((type: TemplateType, variable: string) => { + const textarea = textareaRefs.current[type]; + if (textarea) { + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const current = localTemplates[type]; + const newValue = current.substring(0, start) + variable + current.substring(end); + setLocalTemplates((prev) => ({ ...prev, [type]: newValue })); + setModified((prev) => ({ ...prev, [type]: true })); + // Restore cursor position after variable + setTimeout(() => { + textarea.focus(); + textarea.selectionStart = start + variable.length; + textarea.selectionEnd = start + variable.length; + }, 0); + } else { + // Fallback: append at end + setLocalTemplates((prev) => ({ ...prev, [type]: prev[type] + variable })); + setModified((prev) => ({ ...prev, [type]: true })); + } + }, [localTemplates]); + + const resetToDefault = useCallback((type: TemplateType) => { + setLocalTemplates((prev) => ({ ...prev, [type]: DEFAULT_TEMPLATES[type] })); + setModified((prev) => ({ ...prev, [type]: true })); + }, []); + + const togglePreview = useCallback((type: TemplateType) => { + setShowPreview((prev) => ({ ...prev, [type]: !prev[type] })); + }, []); + + const handleSave = useCallback(() => { + const payload: Record = { clinicId: clinicId as any }; + for (const type of templateTypes) { + if (modified[type]) { + // If it's the same as default, save null (use default) + if (localTemplates[type] === DEFAULT_TEMPLATES[type]) { + (payload as any)[type] = null; + } else { + (payload as any)[type] = localTemplates[type]; + } + } + } + updateMutation.mutate(payload as any); + setModified({ joined: false, soon: false, called: false, withdrawn: false }); + }, [localTemplates, modified, clinicId, updateMutation]); + + const hasAnyModification = Object.values(modified).some(Boolean); + + // Preview rendering with highlighted variables + const renderPreview = useCallback((template: string) => { + const rendered = interpolateTemplate(template, SAMPLE_CONTEXT); + return rendered; + }, []); + + // Highlight variables in template text + const highlightVariables = useCallback((text: string) => { + const parts = text.split(/(\{\{[a-z]+\}\})/g); + return parts.map((part, i) => { + if (part.match(/^\{\{[a-z]+\}\}$/)) { + return ( + + {part} + + ); + } + return {part}; + }); + }, []); + + if (isLoading) { + return ( + + +
+
+
+
+ + + ); + } + + const activeLabel = TEMPLATE_LABELS[activeTab]; + + return ( + + +
+
+ + + Modèles de messages WhatsApp + + + Personnalisez les messages envoyés automatiquement aux patients. Cliquez sur une variable pour l'insérer. + +
+ {hasAnyModification && ( + + )} +
+
+ + {/* Variables reference */} +
+

+ + Variables disponibles +

+
+ {TEMPLATE_VARIABLES.map((v) => ( + + ))} +
+
+ + {/* Template tabs */} +
+ {templateTypes.map((type) => { + const label = TEMPLATE_LABELS[type]; + return ( + + ); + })} +
+ + {/* Active template editor */} +
+
+
+

{activeLabel.icon} {activeLabel.title}

+

{activeLabel.description}

+
+
+ + +
+
+ + {showPreview[activeTab] ? ( + /* Preview mode – WhatsApp-style bubble */ +
+
+
+ {renderPreview(localTemplates[activeTab])} +
+

+ Aperçu avec données fictives +

+
+
+ ) : ( + /* Edit mode */ +
+