diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 710ca71..0000000 --- a/.dockerignore +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index 56156dd..0000000 --- a/.env.example +++ /dev/null @@ -1,61 +0,0 @@ -# ─── 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 4534cef..e69de29 100644 --- a/.gitignore +++ b/.gitignore @@ -1,17 +0,0 @@ -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 new file mode 100644 index 0000000..e69de29 diff --git a/AUTHORS.md b/AUTHORS.md new file mode 100644 index 0000000..e69de29 diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 927a398..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,106 +0,0 @@ -# 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 deleted file mode 100644 index 4372d95..0000000 --- a/Dockerfile +++ /dev/null @@ -1,54 +0,0 @@ -# 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 new file mode 100644 index 0000000..d7021cf Binary files /dev/null and b/MODE_OPERATOIRE_QueueMed.pdf differ diff --git a/ROADMAP.html b/ROADMAP.html new file mode 100644 index 0000000..e69de29 diff --git a/client/index.html b/client/index.html deleted file mode 100644 index f9cfe11..0000000 --- a/client/index.html +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - - - - - - - - - QueueMed — Salle d'attente virtuelle - - - - - -
- - - diff --git a/client/public/favicon.svg b/client/public/favicon.svg deleted file mode 100644 index fce6a66..0000000 --- a/client/public/favicon.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/client/public/icon-192x192.svg b/client/public/icon-192x192.svg deleted file mode 100644 index 9c82f55..0000000 --- a/client/public/icon-192x192.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/client/public/icon-512x512.svg b/client/public/icon-512x512.svg deleted file mode 100644 index 7955f12..0000000 --- a/client/public/icon-512x512.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/client/src/App.tsx b/client/src/App.tsx deleted file mode 100644 index 018b951..0000000 --- a/client/src/App.tsx +++ /dev/null @@ -1,140 +0,0 @@ -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 deleted file mode 100644 index 8811715..0000000 --- a/client/src/_core/hooks/useAuth.ts +++ /dev/null @@ -1,66 +0,0 @@ -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 deleted file mode 100644 index 2d5f805..0000000 --- a/client/src/components/CountryCodeManager.tsx +++ /dev/null @@ -1,291 +0,0 @@ -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 deleted file mode 100644 index 6042801..0000000 --- a/client/src/components/Layout.tsx +++ /dev/null @@ -1,210 +0,0 @@ -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 deleted file mode 100644 index f5a8a94..0000000 --- a/client/src/components/PhoneDialCodePicker.tsx +++ /dev/null @@ -1,242 +0,0 @@ -/** - * 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 deleted file mode 100644 index f9bf950..0000000 --- a/client/src/components/PractitionerManager.tsx +++ /dev/null @@ -1,233 +0,0 @@ -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 deleted file mode 100644 index 63920bd..0000000 --- a/client/src/components/WhatsAppTemplateEditor.tsx +++ /dev/null @@ -1,352 +0,0 @@ -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 */ -
-